From c4a9825c8e25b442fd1c09cb674648cb4b789080 Mon Sep 17 00:00:00 2001
From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com>
Date: Wed, 29 Jul 2026 13:33:01 -0400
Subject: [PATCH 01/26] feat(ipfs2filecoin): add IPFS to Filecoin campaign
landing page
Adds filecoin.cloud/ipfs2filecoin, the front door for the IPFS-to-Filecoin
mini-campaign, ported from Jenni's wireframe in FilOzone/FOC-PM onto the
site's existing ui-filecoin components.
The page is both the campaign's front door and its measuring instrument. It
does three things: takes a pasted CID list, produces a deposit estimate from a
declared volume, and routes to the agent door or to contact.
Interactive parts run entirely client-side, with no wallet and no network
calls, so a check costs the visitor nothing and sends us nothing:
- CID list checker: validates, dedupes, and steers lists over the 500-item
browser cap to the agent prompt.
- Deposit estimator: reproduces the PRD's worked example exactly ($128.08
deposit, $5.25 refundable for 1 TiB over two years) from documented
constants rather than hardcoded figures.
- Agent prompt with copy-to-clipboard, the primary conversion.
Plausible events cover the funnel the PRD asks for: CID List Checked,
Estimate Viewed, and Agent Prompt Copied, the last being the headline number.
Also serves a draft migration brief at /ipfs2filecoin/llms.txt and advertises
it via , so an agent pointed at the
human URL discovers the brief without being told the convention. The brief
opens with a stage 0 ingress check, per the 2026-07-28 validation run that
found runs die there after the expensive commP pass.
The page is unlinked from site navigation, reachable by URL and sitemap only,
so campaign attribution stays clean.
---
.../ipfs2filecoin/components/AgentPrompt.tsx | 42 +++
.../components/CidListChecker.tsx | 191 +++++++++++++
.../components/ComparisonTable.tsx | 56 ++++
.../components/CostEstimator.tsx | 225 +++++++++++++++
src/app/ipfs2filecoin/constants/migration.ts | 40 +++
src/app/ipfs2filecoin/constants/seo.ts | 5 +
src/app/ipfs2filecoin/data/faqs.tsx | 109 +++++++
src/app/ipfs2filecoin/data/limits.ts | 31 ++
.../ipfs2filecoin/data/pricing-comparison.ts | 37 +++
src/app/ipfs2filecoin/data/reasons.ts | 22 ++
src/app/ipfs2filecoin/data/steps.ts | 26 ++
src/app/ipfs2filecoin/llms.txt/route.ts | 121 ++++++++
src/app/ipfs2filecoin/page.tsx | 267 ++++++++++++++++++
src/app/ipfs2filecoin/utils/estimate-cost.ts | 78 +++++
.../utils/generate-structured-data.ts | 23 ++
src/app/ipfs2filecoin/utils/parse-cid-list.ts | 40 +++
src/constants/paths.ts | 4 +
17 files changed, 1317 insertions(+)
create mode 100644 src/app/ipfs2filecoin/components/AgentPrompt.tsx
create mode 100644 src/app/ipfs2filecoin/components/CidListChecker.tsx
create mode 100644 src/app/ipfs2filecoin/components/ComparisonTable.tsx
create mode 100644 src/app/ipfs2filecoin/components/CostEstimator.tsx
create mode 100644 src/app/ipfs2filecoin/constants/migration.ts
create mode 100644 src/app/ipfs2filecoin/constants/seo.ts
create mode 100644 src/app/ipfs2filecoin/data/faqs.tsx
create mode 100644 src/app/ipfs2filecoin/data/limits.ts
create mode 100644 src/app/ipfs2filecoin/data/pricing-comparison.ts
create mode 100644 src/app/ipfs2filecoin/data/reasons.ts
create mode 100644 src/app/ipfs2filecoin/data/steps.ts
create mode 100644 src/app/ipfs2filecoin/llms.txt/route.ts
create mode 100644 src/app/ipfs2filecoin/page.tsx
create mode 100644 src/app/ipfs2filecoin/utils/estimate-cost.ts
create mode 100644 src/app/ipfs2filecoin/utils/generate-structured-data.ts
create mode 100644 src/app/ipfs2filecoin/utils/parse-cid-list.ts
diff --git a/src/app/ipfs2filecoin/components/AgentPrompt.tsx b/src/app/ipfs2filecoin/components/AgentPrompt.tsx
new file mode 100644
index 00000000..1569d9f6
--- /dev/null
+++ b/src/app/ipfs2filecoin/components/AgentPrompt.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { Button } from '@filecoin-foundation/ui-filecoin/Button'
+import { usePlausible } from 'next-plausible'
+
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+
+import { AGENT_PROMPT, PLAUSIBLE_EVENTS } from '../constants/migration'
+
+type AgentPromptProps = {
+ /** Where on the page the prompt was copied from, so the two spots stay distinguishable. */
+ source: 'verdict' | 'agent-door'
+}
+
+export function AgentPrompt({ source }: AgentPromptProps) {
+ const { copy, isCopied } = useCopyToClipboard()
+ const plausible = usePlausible()
+
+ async function handleCopy() {
+ const copied = await copy(AGENT_PROMPT)
+
+ if (copied) {
+ plausible(PLAUSIBLE_EVENTS.promptCopied, { props: { source } })
+ }
+ }
+
+ return (
+
+ Checking is free and needs no wallet, up to{' '}
+ {BROWSER_CHECK_ITEM_CAP.toLocaleString()} items and{' '}
+ {MAX_ITEM_SIZE_LABEL} per item. It runs entirely in your browser, so
+ your list is never sent anywhere. Past that,{' '}
+
+ hand the migration to your agent
+
+ .
+
+
+ None of these look like CIDs.
+ {' '}
+ {summary.totalLines} {pluralize(summary.totalLines, 'line')} could not
+ be read as a CID. Check for extra text, URLs, or commas.
+
+
+ {count.toLocaleString()} CIDs is more than a browser check handles.
+ {' '}
+ The limit here is {BROWSER_CHECK_ITEM_CAP.toLocaleString()} items.
+ Hand the list to your agent instead: no cap, and it resumes if it
+ stops. {notes}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {count.toLocaleString()} {pluralize(count, 'CID')} ready to check.
+ {' '}
+ Estimate what they cost to store below, then hand the list to your agent
+ to fetch, measure, and migrate them. {notes}
+
+ )
+}
diff --git a/src/app/ipfs2filecoin/components/ComparisonTable.tsx b/src/app/ipfs2filecoin/components/ComparisonTable.tsx
new file mode 100644
index 00000000..9b7a73b7
--- /dev/null
+++ b/src/app/ipfs2filecoin/components/ComparisonTable.tsx
@@ -0,0 +1,56 @@
+import { clsx } from 'clsx'
+
+import { pricingComparison } from '../data/pricing-comparison'
+
+export function ComparisonTable() {
+ return (
+
+ Based on {formatUsd(USD_PER_TIB_MONTH_PER_COPY)} per TiB per month per
+ copy plus proving, across {COPIES} copies. Gas is separate, so you also
+ need a small amount of FIL. A deposit funds a rate rather than a term:
+ storing more data later spends the balance faster and moves your
+ funded-until date earlier. The exact deposit is shown before you approve
+ it.
+
+
+ )
+}
+
+function EstimateBreakdown({ estimate }: { estimate: CostEstimate }) {
+ const durationLabel =
+ DURATION_OPTIONS.find(({ days }) => days === estimate.days)?.label ??
+ `${estimate.days} days`
+
+ const rows = [
+ {
+ label: `Storage and proving for ${durationLabel}`,
+ amount: formatUsd(estimate.storage),
+ note: 'No, this is the cost',
+ },
+ {
+ label: `Buffer held while data is live (${BUFFER_DAYS} days of charges)`,
+ amount: formatUsd(estimate.buffer),
+ note: 'Yes, refundable',
+ },
+ {
+ label: 'Lifecycle reserve, per data set',
+ amount: formatUsd(estimate.lifecycleReserve),
+ note: 'Yes, unused portion',
+ },
+ ]
+
+ return (
+
+
+
+
+
+ Line
+
+
+ Amount
+
+
+ Yours again afterwards
+
+
+
+
+ {rows.map(({ label, amount, note }) => (
+
+
{label}
+
{amount}
+
{note}
+
+ ))}
+
+
Deposit
+
+ {formatUsd(estimate.deposit)}
+
+
+ {formatUsd(estimate.refundable)} comes back
+
+
+
+
+
+ The buffer is set aside, not spent, and you get back whatever is unused.
+ Treat your funded-until date as the date to act by rather than the date
+ service stops: if the balance runs out, providers can end the service
+ and keep the buffer. Depositing for longer is not a discount, it just
+ means fewer trips back.
+
+
+ )
+}
diff --git a/src/app/ipfs2filecoin/constants/migration.ts b/src/app/ipfs2filecoin/constants/migration.ts
new file mode 100644
index 00000000..f226ed63
--- /dev/null
+++ b/src/app/ipfs2filecoin/constants/migration.ts
@@ -0,0 +1,40 @@
+import { BASE_URL } from '@/constants/site-metadata'
+
+/**
+ * Caps for the browser-based check. Larger inputs are steered to the CLI or to
+ * the contact form, because they need capacity agreed with providers up front.
+ */
+export const BROWSER_CHECK_ITEM_CAP = 500
+export const MAX_ITEM_SIZE_LABEL = '1 GiB'
+export const SELF_SERVE_VOLUME_CAP_LABEL = '500 GiB'
+
+/** Warm storage list price, per copy. Two copies are stored by default. */
+export const USD_PER_TIB_MONTH_PER_COPY = 2.5
+export const COPIES = 2
+
+/** Proving buffer charged alongside storage, per TiB per month per copy. */
+export const USD_PROVING_PER_TIB_MONTH_PER_COPY = 0.024
+
+/** Held per data set, and one data set is created per copy. Unused portion returns. */
+export const USD_LIFECYCLE_RESERVE_PER_DATA_SET = 0.1
+
+/** A billing month is a 30-day period, matching how storage is charged. */
+export const DAYS_PER_MONTH = 30
+
+/**
+ * Days of charges held in reserve while data is stored. Set aside rather than
+ * spent, and returned when the data set is closed.
+ */
+export const BUFFER_DAYS = 30
+
+export const LLMS_TXT_PATH = '/ipfs2filecoin/llms.txt'
+export const LLMS_TXT_URL = `${BASE_URL}${LLMS_TXT_PATH}`
+
+/** The one line a user hands to a coding agent. Copying it is signal, so it is tracked. */
+export const AGENT_PROMPT = `Migrate my IPFS data to Filecoin: read ${LLMS_TXT_URL} and follow it. My CIDs are in cids.txt.`
+
+export const PLAUSIBLE_EVENTS = {
+ cidListChecked: 'IPFS2Filecoin CID List Checked',
+ estimateViewed: 'IPFS2Filecoin Estimate Viewed',
+ promptCopied: 'IPFS2Filecoin Agent Prompt Copied',
+} as const
diff --git a/src/app/ipfs2filecoin/constants/seo.ts b/src/app/ipfs2filecoin/constants/seo.ts
new file mode 100644
index 00000000..78c4d3e9
--- /dev/null
+++ b/src/app/ipfs2filecoin/constants/seo.ts
@@ -0,0 +1,5 @@
+export const IPFS2FILECOIN_SEO = {
+ title: 'IPFS to Filecoin | Move Your Pinned Data Without Changing Your CIDs',
+ description:
+ 'Move pinned IPFS data to Filecoin warm storage. Same CIDs, $2.50 per TiB per month per copy, and an onchain receipt you can verify yourself.',
+} as const
diff --git a/src/app/ipfs2filecoin/data/faqs.tsx b/src/app/ipfs2filecoin/data/faqs.tsx
new file mode 100644
index 00000000..6ff1406b
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/faqs.tsx
@@ -0,0 +1,109 @@
+import { MarkdownLink } from '@filecoin-foundation/ui-filecoin/Markdown/MarkdownLink'
+
+import type { Question } from '@/components/Faq'
+
+import { PATHS } from '@/constants/paths'
+
+import { LLMS_TXT_PATH } from '../constants/migration'
+
+export const faqs: Array = [
+ {
+ question: 'Do my existing gateway links keep working?',
+ answer: (
+ <>
+
+ Yes. Data is stored as the CAR of the original DAG, so the CID is
+ unchanged and the content stays announced to the IPFS network.
+ Anything already published, whether an IPNS name, a token URI, or a
+ link in a document, resolves the same way after the migration as
+ before it.
+
+
+ That holds for any public gateway, a gateway you run yourself, or a
+ local node, because resolution goes through the network rather than
+ through us.
+
+ >
+ ),
+ },
+ {
+ question: 'Do I have to delete the data from my current provider?',
+ answer: (
+
+ No, and we would suggest you do not until you have verified the
+ migration yourself. Run the retrieval check on the receipt, keep the
+ manifest, and cancel your old plan when you are satisfied.
+
+ ),
+ },
+ {
+ question: 'What do I need before I can migrate?',
+ answer: (
+ <>
+
+ USDFC to pay for storage and a small amount of FIL for gas, in a
+ wallet you control. Checking a list needs none of that, and you can
+ set the rest up in one pass: connect a wallet, deposit, approve
+ spending.
+
+
+ Those are ordinary transactions you confirm one at a time. After that
+ your account is ready and the migration itself can run without you
+ watching it.
+
+ >
+ ),
+ },
+ {
+ question: 'What happens if the run stops halfway?',
+ answer: (
+
+ State is kept, so closing the tab does not lose the work and the run
+ picks up where it left off. Anything not yet stored stays in the
+ manifest so you can finish it here or carry it to the command line.
+
+ ),
+ },
+ {
+ question: 'What can the agent actually spend?',
+ answer: (
+ <>
+
+ Only what you have deposited. The migration runs from a key you
+ provide, and a key that can sign on your account can also draw against
+ its balance, so treat the deposit as the ceiling rather than as a
+ starting balance.
+
+
+ The safe pattern is a wallet you keep for this purpose, holding the
+ USDFC and FIL this migration needs and nothing else. Do not give a key
+ to your main wallet to anything that runs unattended, here or
+ anywhere.
+
+ >
+ ),
+ },
+ {
+ question: 'How much of this can an agent actually do?',
+ answer: (
+ <>
+
+ All of the work, and none of the spending. The brief at{' '}
+ {LLMS_TXT_PATH}{' '}
+ gives an agent the command sequence, the caps, and the prerequisites,
+ and the tool runs headless with JSON status output so it can read its
+ own progress and recover from failures.
+
+
+ What it hands back to you are the two steps that move money: funding
+ the run and signing it. If a migration is too large or your source is
+ not publicly reachable,{' '}
+
+ talk to our team
+ {' '}
+ instead.
+
+ >
+ ),
+ },
+]
diff --git a/src/app/ipfs2filecoin/data/limits.ts b/src/app/ipfs2filecoin/data/limits.ts
new file mode 100644
index 00000000..5f4c863d
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/limits.ts
@@ -0,0 +1,31 @@
+import {
+ MAX_ITEM_SIZE_LABEL,
+ SELF_SERVE_VOLUME_CAP_LABEL,
+} from '../constants/migration'
+
+export const limits = [
+ {
+ title: 'Your source has to be publicly readable',
+ description:
+ 'Each CID is fetched through a public gateway that serves verifiable content. Private pinning accounts, authenticated endpoints, and custom storage are not reachable this way, so those go through the contact form.',
+ },
+ {
+ title: `${MAX_ITEM_SIZE_LABEL} per item`,
+ description: `Individual assets larger than ${MAX_ITEM_SIZE_LABEL} cannot be moved yet, because splitting them would change their CID and that is the one thing we will not do. Large sets of small items are fine.`,
+ },
+ {
+ title: `Past ${SELF_SERVE_VOLUME_CAP_LABEL}, talk to us first`,
+ description:
+ 'A migration that size needs capacity and timing agreed with storage providers before it starts. We would rather scope it with you up front than have you discover the ceiling partway through a run.',
+ },
+ {
+ title: 'Filecoin uses its own identifier underneath',
+ description:
+ 'Filecoin tracks storage under its own piece identifier, and your run produces a manifest mapping every IPFS CID to it. You never need that identifier to read your data, because retrieval is always by your original CID.',
+ },
+ {
+ title: 'Unreachable CIDs are reported, not hidden',
+ description:
+ 'If a CID cannot be fetched from any gateway you provided, the run tells you which ones and how many, and the manifest records them. A count you can act on beats a silent partial success.',
+ },
+] as const
diff --git a/src/app/ipfs2filecoin/data/pricing-comparison.ts b/src/app/ipfs2filecoin/data/pricing-comparison.ts
new file mode 100644
index 00000000..94123826
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/pricing-comparison.ts
@@ -0,0 +1,37 @@
+import { formatUsd, USD_PER_TB_MONTH } from '../utils/estimate-cost'
+
+export type PricingRow = {
+ service: string
+ storagePerTbMonth: string
+ egress: string
+ highlighted?: boolean
+}
+
+/**
+ * Third-party rates are published list overage rates, captured 27 July 2026.
+ * Overage is the rate that matters once you are past a plan's included
+ * allowance, which anyone migrating a real archive already is.
+ */
+export const pricingComparison: Array = [
+ {
+ service: 'Filecoin Warm Storage',
+ storagePerTbMonth: formatUsd(USD_PER_TB_MONTH),
+ egress: 'IPFS gateway read path',
+ highlighted: true,
+ },
+ {
+ service: 'Filebase, Pro overage',
+ storagePerTbMonth: '$15.00',
+ egress: '$0.015 / GB',
+ },
+ {
+ service: 'Pinata, Fiesta overage',
+ storagePerTbMonth: '$35.00',
+ egress: '$0.080 / GB',
+ },
+ {
+ service: 'Pinata, Picnic overage',
+ storagePerTbMonth: '$70.00',
+ egress: '$0.100 / GB',
+ },
+]
diff --git a/src/app/ipfs2filecoin/data/reasons.ts b/src/app/ipfs2filecoin/data/reasons.ts
new file mode 100644
index 00000000..8eefd413
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/reasons.ts
@@ -0,0 +1,22 @@
+export const reasons = [
+ {
+ title: 'Your CIDs do not change',
+ description:
+ 'Nothing is re-chunked. Every CID stays byte-identical and keeps resolving from any public IPFS gateway, so existing links, IPNS names, and NFT metadata keep working exactly as they do today.',
+ },
+ {
+ title: 'A fraction of pinning-service pricing',
+ description:
+ '$2.50 per TiB per month per copy, two copies by default. You pay storage providers directly, streamed per epoch, with no plan tiers and no minimum commitment.',
+ },
+ {
+ title: 'Proof, not a dashboard',
+ description:
+ 'Storage providers prove possession onchain on a schedule. You get a receipt with transaction links, and you can verify any piece yourself at any time without asking anyone.',
+ },
+ {
+ title: 'Your wallet owns it, not an account',
+ description:
+ 'No signup and no vendor account to lose. The data set belongs to your wallet address and payment streams from your wallet straight to the providers. Reading your data never depends on your key, so the content stays reachable either way.',
+ },
+] as const
diff --git a/src/app/ipfs2filecoin/data/steps.ts b/src/app/ipfs2filecoin/data/steps.ts
new file mode 100644
index 00000000..eb5b81b9
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/steps.ts
@@ -0,0 +1,26 @@
+export const steps = [
+ {
+ number: '01',
+ title: 'Hand over the list',
+ description:
+ 'Give your agent a cids.txt, or paste a list here first to see what you are dealing with. Checking costs nothing.',
+ },
+ {
+ number: '02',
+ title: 'Read and measure',
+ description:
+ 'Each CID is fetched from a public gateway and its Filecoin piece identifier computed. Nothing is uploaded anywhere and nothing is charged.',
+ },
+ {
+ number: '03',
+ title: 'Fund it, once',
+ description:
+ 'Connect a wallet, deposit USDFC, approve spending. Use a wallet you keep for this and deposit what the migration needs, because that amount is also the most anything can spend.',
+ },
+ {
+ number: '04',
+ title: 'Migrate',
+ description:
+ 'Storage providers pull the data and commit it onchain. You get a receipt, explorer links, and a manifest of everything that landed.',
+ },
+] as const
diff --git a/src/app/ipfs2filecoin/llms.txt/route.ts b/src/app/ipfs2filecoin/llms.txt/route.ts
new file mode 100644
index 00000000..5f57935c
--- /dev/null
+++ b/src/app/ipfs2filecoin/llms.txt/route.ts
@@ -0,0 +1,121 @@
+import { PATHS } from '@/constants/paths'
+import { BASE_URL, FOC_URLS } from '@/constants/site-metadata'
+
+import {
+ BROWSER_CHECK_ITEM_CAP,
+ COPIES,
+ MAX_ITEM_SIZE_LABEL,
+ SELF_SERVE_VOLUME_CAP_LABEL,
+ USD_PER_TIB_MONTH_PER_COPY,
+} from '../constants/migration'
+
+/**
+ * Placeholder migration brief. The full command sequence lives with the tool
+ * that runs it, in FilOzone/ipfs2foc, and replaces the body of this route once
+ * it is published. Serving it from here now means the prompt on the landing
+ * page resolves and we can see who is fetching it.
+ */
+const LLMS_TXT = `# IPFS to Filecoin — migration brief
+
+> DRAFT. This brief is published ahead of the full command sequence. It states
+> what the migration does, what it costs, and where it stops. Do not infer
+> commands that are not listed here; check the tool's README instead.
+
+## What this is
+
+A migration of pinned IPFS data into Filecoin warm storage. Content is stored as
+the CAR of the original DAG, so every CID stays byte-identical and keeps
+resolving from any public IPFS gateway. Storage providers prove possession
+onchain on a schedule.
+
+## Prerequisites
+
+- Node.js 26 or newer. This is a hard requirement.
+- A list of CIDs, one per line, in a text file (conventionally cids.txt).
+- Each CID must be retrievable from a public IPFS gateway. Private pinning
+ accounts and authenticated endpoints are not reachable this way.
+- A wallet holding USDFC for storage and a small amount of FIL for gas.
+ Use a wallet kept for this purpose. The deposited balance is the ceiling on
+ what any unattended run can spend.
+- A public HTTPS origin the storage provider can pull pieces from. See stage 0.
+
+## Stage 0 — check the ingress before anything else
+
+At submission a storage provider pulls pieces from a public HTTPS origin that
+you supply. Private and loopback addresses are rejected. Confirm you have one
+of these BEFORE computing piece commitments, because discovering it afterwards
+wastes the entire run:
+
+- the \`cloudflared\` binary installed, for \`--ingress cloudflared\`; or
+- a VPS or Tailscale fronting the port yourself, for \`--ingress funnel\`; or
+- a source relay URL, for \`--source-relay\`.
+
+If none is available, stop and report that to the user rather than choosing one
+on their behalf. Installing a tunnel binary and exposing a local port to the
+internet is the user's decision to make.
+
+## Caps
+
+- ${MAX_ITEM_SIZE_LABEL} per item. Larger assets cannot be moved yet, because
+ splitting them would change their CID.
+- ${SELF_SERVE_VOLUME_CAP_LABEL} total for a self-serve run. Past that, capacity
+ and timing must be agreed with storage providers first: ${BASE_URL}${PATHS.CONTACT.path}
+- ${BROWSER_CHECK_ITEM_CAP} items for the in-browser check on the landing page.
+ The command-line tool has no item cap and resumes if a run stops.
+
+## Cost
+
+Storage is ${USD_PER_TIB_MONTH_PER_COPY} USDFC per TiB per month per copy, at
+${COPIES} copies by default, streamed to providers per epoch. A small proving
+charge is billed alongside it. Funding is a deposit covering a chosen period,
+plus a refundable 30-day buffer and a per-data-set lifecycle reserve held while
+the data is stored. Gas is paid separately in FIL.
+
+A deposit funds a rate, not a term. Funded duration is the available balance
+divided by the burn rate across every data set on the account, so storing more
+data later moves the funded-until date earlier.
+
+## Stages
+
+1. analyze — read the CID list, fetch each CID from a public gateway, compute
+ its Filecoin piece identifier and size. Free, no wallet, nothing charged.
+2. plan — produce the deposit required for the chosen retention period, and the
+ list of CIDs that could not be retrieved.
+3. fund — deposit USDFC and approve spending. This step moves money and is
+ handed back to the human.
+4. migrate — providers pull the data and commit it onchain.
+5. verify — retrieve by original CID and check the onchain proof, then keep the
+ manifest mapping every IPFS CID to its Filecoin piece identifier.
+
+## Failure modes
+
+- Unreachable CIDs are reported with counts and recorded in the manifest. They
+ are never silently dropped.
+- A stopped run resumes from its recorded state. Nothing already stored is
+ re-paid for.
+- If the funded balance runs out, providers can end the service and keep the
+ buffer. Top up before the funded-until date.
+
+## Do not
+
+- Re-chunk, repackage, or otherwise alter content. Changing a CID defeats the
+ point of the migration.
+- Delete data from the existing pinning provider until the migration has been
+ verified.
+- Use a key to a wallet that holds anything beyond what this migration needs.
+
+## Links
+
+- Landing page: ${BASE_URL}${PATHS.IPFS_TO_FILECOIN.path}
+- Documentation: ${FOC_URLS.documentation.home}
+- Talk to our team: ${BASE_URL}${PATHS.CONTACT.path}
+`
+
+export function GET() {
+ return new Response(LLMS_TXT, {
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Cache-Control': 'public, max-age=0, s-maxage=3600',
+ },
+ })
+}
diff --git a/src/app/ipfs2filecoin/page.tsx b/src/app/ipfs2filecoin/page.tsx
new file mode 100644
index 00000000..3a1ed035
--- /dev/null
+++ b/src/app/ipfs2filecoin/page.tsx
@@ -0,0 +1,267 @@
+import { Button } from '@filecoin-foundation/ui-filecoin/Button'
+import { Card } from '@filecoin-foundation/ui-filecoin/Card'
+import { CardGrid } from '@filecoin-foundation/ui-filecoin/CardGrid'
+import { Container } from '@filecoin-foundation/ui-filecoin/Container'
+import { Heading } from '@filecoin-foundation/ui-filecoin/Heading'
+import { PageHeader } from '@filecoin-foundation/ui-filecoin/PageHeader'
+import { PageSection } from '@filecoin-foundation/ui-filecoin/PageSection'
+import { SectionContent } from '@filecoin-foundation/ui-filecoin/SectionContent'
+import { ExternalTextLink } from '@filecoin-foundation/ui-filecoin/TextLink/ExternalTextLink'
+import { SmartTextLink } from '@filecoin-foundation/ui-filecoin/TextLink/SmartTextLink'
+import type { Metadata } from 'next'
+
+import { Faq } from '@/components/Faq'
+import { Navigation } from '@/components/Navigation/Navigation'
+import { StructuredDataScript } from '@/components/StructuredDataScript'
+
+import { PATHS } from '@/constants/paths'
+import { FOC_URLS } from '@/constants/site-metadata'
+import { createMetadata } from '@/utils/create-metadata'
+
+import { AgentPrompt } from './components/AgentPrompt'
+import { CidListChecker } from './components/CidListChecker'
+import { ComparisonTable } from './components/ComparisonTable'
+import { CostEstimator } from './components/CostEstimator'
+import {
+ LLMS_TXT_PATH,
+ SELF_SERVE_VOLUME_CAP_LABEL,
+} from './constants/migration'
+import { IPFS2FILECOIN_SEO } from './constants/seo'
+import { faqs } from './data/faqs'
+import { limits } from './data/limits'
+import { reasons } from './data/reasons'
+import { steps } from './data/steps'
+import { generateStructuredData } from './utils/generate-structured-data'
+
+const ghostOnDark =
+ '!border-zinc-50/40 !bg-transparent hover:!border-zinc-50 hover:!bg-zinc-50/5'
+
+export default function IpfsToFilecoin() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Filecoin Warm Storage is $2.50 per TiB per month per copy at two
+ copies. Other rates are published list overage rates as of 27 July
+ 2026, from{' '}
+
+ Filebase
+ {' '}
+ and{' '}
+
+ Pinata
+
+ . Filecoin includes two replicas; the other services do not
+ publish a replication factor, so the comparison is conservative.
+
+
+
+
+
+
+
+
+
+ Estimate your deposit
+
+
+ Storage is charged continuously while your data sits there, so you
+ deposit for a period rather than paying a monthly bill. Enter
+ roughly how much you are holding and how long you want to be
+ covered. This runs in your browser and nothing is sent anywhere.
+
+ Give this line to Claude Code, Cursor, or any coding agent. It
+ reads the migration brief at{' '}
+
+ {LLMS_TXT_PATH}
+
+ , works through your list, and reports back what landed.
+
+
+ It signs from a key you provide, and a key that can sign on your
+ account can draw against its balance, so your deposit is the
+ ceiling. Use a wallet you keep for this purpose, holding what
+ this migration needs and nothing else. Never give a main
+ wallet's key to something running unattended.
+
+
+
+
+
+
+ Talk to us
+
+
+ For datasets over {SELF_SERVE_VOLUME_CAP_LABEL}, or sources that
+ are not reachable from a public gateway. Tell us what you are
+ holding and we will scope it with you.
+
+
+
+
+
+
+
+
+
+
+ {limits.map(({ title, description }) => (
+
+
+ {title}
+
+
+ {description}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Read the docs
+ ,
+ ,
+ ]}
+ />
+
+ >
+ )
+}
+
+const baseMetadata = createMetadata({
+ title: IPFS2FILECOIN_SEO.title,
+ description: IPFS2FILECOIN_SEO.description,
+ path: PATHS.IPFS_TO_FILECOIN.path,
+})
+
+export const metadata: Metadata = {
+ ...baseMetadata,
+ alternates: {
+ ...baseMetadata.alternates,
+ /**
+ * So an agent pointed at the human URL discovers the migration brief
+ * without being told the llms.txt convention.
+ */
+ types: { 'text/markdown': LLMS_TXT_PATH },
+ },
+}
diff --git a/src/app/ipfs2filecoin/utils/estimate-cost.ts b/src/app/ipfs2filecoin/utils/estimate-cost.ts
new file mode 100644
index 00000000..99a79979
--- /dev/null
+++ b/src/app/ipfs2filecoin/utils/estimate-cost.ts
@@ -0,0 +1,78 @@
+import {
+ BUFFER_DAYS,
+ COPIES,
+ DAYS_PER_MONTH,
+ USD_LIFECYCLE_RESERVE_PER_DATA_SET,
+ USD_PER_TIB_MONTH_PER_COPY,
+ USD_PROVING_PER_TIB_MONTH_PER_COPY,
+} from '../constants/migration'
+
+const BYTES_PER_TIB = 1024 ** 4
+const BYTES_PER_TB = 1000 ** 4
+
+/** Storage alone. This is the rate the comparison table quotes. */
+export const USD_STORAGE_PER_TIB_MONTH = USD_PER_TIB_MONTH_PER_COPY * COPIES
+
+/**
+ * The comparison table quotes per-TB rates, because that is how pinning
+ * services publish theirs. Warm storage is priced per TiB.
+ */
+export const USD_PER_TB_MONTH =
+ USD_STORAGE_PER_TIB_MONTH * (BYTES_PER_TB / BYTES_PER_TIB)
+
+/**
+ * What actually gets charged per epoch: storage plus the proving buffer, on
+ * every copy. Used for the deposit, not for the headline comparison.
+ */
+export const USD_BURN_PER_TIB_MONTH =
+ (USD_PER_TIB_MONTH_PER_COPY + USD_PROVING_PER_TIB_MONTH_PER_COPY) * COPIES
+
+/** One data set is created per copy, each holding its own lifecycle reserve. */
+export const USD_LIFECYCLE_RESERVE = USD_LIFECYCLE_RESERVE_PER_DATA_SET * COPIES
+
+export type VolumeUnit = 'GiB' | 'TiB'
+
+export type CostEstimate = {
+ tebibytes: number
+ days: number
+ /** Charges consumed over the funded period. This is the actual cost. */
+ storage: number
+ /** Held while the data is stored, returned when the data set is closed. */
+ buffer: number
+ /** Held per data set for lifecycle operations; the unused portion returns. */
+ lifecycleReserve: number
+ /** What you deposit up front. */
+ deposit: number
+ /** The portion of the deposit you get back. */
+ refundable: number
+}
+
+export function toTebibytes(volume: number, unit: VolumeUnit) {
+ return unit === 'TiB' ? volume : volume / 1024
+}
+
+export function estimateCost(tebibytes: number, days: number): CostEstimate {
+ const perMonth = tebibytes * USD_BURN_PER_TIB_MONTH
+ const storage = perMonth * (days / DAYS_PER_MONTH)
+ const buffer = perMonth * (BUFFER_DAYS / DAYS_PER_MONTH)
+ const lifecycleReserve = USD_LIFECYCLE_RESERVE
+
+ return {
+ tebibytes,
+ days,
+ storage,
+ buffer,
+ lifecycleReserve,
+ deposit: storage + buffer + lifecycleReserve,
+ refundable: buffer + lifecycleReserve,
+ }
+}
+
+export function formatUsd(amount: number) {
+ return amount.toLocaleString('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+}
diff --git a/src/app/ipfs2filecoin/utils/generate-structured-data.ts b/src/app/ipfs2filecoin/utils/generate-structured-data.ts
new file mode 100644
index 00000000..f2ee7fa1
--- /dev/null
+++ b/src/app/ipfs2filecoin/utils/generate-structured-data.ts
@@ -0,0 +1,23 @@
+import type { WebPageGraph } from '@/components/StructuredDataScript'
+
+import { PATHS } from '@/constants/paths'
+import type { StructuredDataParams } from '@/types/structured-data-params'
+import { generatePageStructuredData } from '@/utils/generate-page-structured-data'
+
+export function generateStructuredData(
+ seo: StructuredDataParams,
+): WebPageGraph {
+ return generatePageStructuredData({
+ title: seo.title,
+ description: seo.description,
+ path: PATHS.IPFS_TO_FILECOIN.path,
+ pageType: 'WebPage',
+ service: {
+ name: 'IPFS to Filecoin migration',
+ description:
+ 'Move pinned IPFS data to Filecoin warm storage without changing your CIDs, with onchain proof of possession.',
+ serviceType: 'Cloud Storage',
+ areaServed: 'Worldwide',
+ },
+ })
+}
diff --git a/src/app/ipfs2filecoin/utils/parse-cid-list.ts b/src/app/ipfs2filecoin/utils/parse-cid-list.ts
new file mode 100644
index 00000000..e7cb8613
--- /dev/null
+++ b/src/app/ipfs2filecoin/utils/parse-cid-list.ts
@@ -0,0 +1,40 @@
+/**
+ * CIDv0 (base58btc, `Qm…`) and CIDv1 (base32, `b…`). Deliberately narrow: this
+ * check runs in the browser with no network access, so it only tells you
+ * whether a line could be a CID, not whether the content is retrievable.
+ */
+const CID_PATTERN = /^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{58,})$/
+
+const GATEWAY_PREFIX_PATTERN = /^(?:ipfs:\/\/|https?:\/\/[^/]+\/ipfs\/)/
+
+export type CidListSummary = {
+ totalLines: number
+ uniqueCids: Array
+ duplicateCount: number
+ invalidCount: number
+}
+
+function normalizeLine(line: string) {
+ return line
+ .trim()
+ .replace(/[,;]$/, '')
+ .replace(GATEWAY_PREFIX_PATTERN, '')
+ .split(/[?#/]/)[0]
+}
+
+export function parseCidList(input: string): CidListSummary {
+ const lines = input
+ .split('\n')
+ .map(normalizeLine)
+ .filter((line) => line && !line.startsWith('#'))
+
+ const valid = lines.filter((line) => CID_PATTERN.test(line))
+ const uniqueCids = [...new Set(valid)]
+
+ return {
+ totalLines: lines.length,
+ uniqueCids,
+ duplicateCount: valid.length - uniqueCids.length,
+ invalidCount: lines.length - valid.length,
+ }
+}
diff --git a/src/constants/paths.ts b/src/constants/paths.ts
index 4cc13e15..2758c240 100644
--- a/src/constants/paths.ts
+++ b/src/constants/paths.ts
@@ -18,6 +18,10 @@ export const PATHS = {
path: '/',
label: 'Homepage',
},
+ IPFS_TO_FILECOIN: {
+ path: '/ipfs2filecoin',
+ label: 'IPFS to Filecoin',
+ },
PRIVACY_POLICY: {
path: '/privacy-policy',
label: 'Privacy Policy',
From f8d4d449d8e602808c6bfe9118839edd70c581b5 Mon Sep 17 00:00:00 2001
From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com>
Date: Wed, 29 Jul 2026 15:44:21 -0400
Subject: [PATCH 02/26] feat(ipfs2filecoin): replace the draft brief with an
executable runbook
A real agent run against the draft brief failed at the first hop: the brief
never named the tool, so the agent correctly refused to improvise a pipeline
and stopped. It also read the 500 GiB figure as a self-serve cap and routed a
56k-CID job to the contact form, which killed the conversion outright.
Both are fixed, and the format question behind them is settled.
llms.txt was the wrong format. Per llmstxt.org it is a documentation index --
H1, blockquote, then H2 lists of links -- so a procedure does not belong in
it. The procedure now lives at /ipfs2filecoin/migrate.md, named for what it
is, and llms.txt is used correctly in two places: a spec-conformant site
index at /llms.txt, and a campaign index at /ipfs2filecoin/llms.txt whose
first link is the runbook. Discovery never depended on the filename anyway --
the prompt carries the URL, and the page carries rel="alternate".
The runbook names ipfs2foc, pins the version its flags were verified against,
and carries the real command sequence read out of that repo. Notable content:
- One invocation form, stated once. A bare `ipfs2foc` after only an `npx`
check was the most likely first failure.
- Prerequisites split into "needed now" and "needed before stage 4", so a
missing key no longer blocks the free stages.
- Stage 0 settles ingress before the commitment pass, and records that the
relay only serves the single-asset path.
- Stage 2 reports from analyze --json using fields that actually exist, with
explicit cost and ETA formulas so nothing is invented.
- Stage 4 is executed by the user in their own terminal. The agent is told
never to accept a private key -- a key pasted into a chat is a key in a
transcript.
- Stage 5 spawns the pull source as a background process and extracts the
tunnel URL from its log, rather than saying "terminal A".
- Duration is framed as expected, not as a limit: a long-running command is
working, not hung, and there is no cap on CID count.
Page copy follows: SELF_SERVE_VOLUME_CAP_LABEL becomes
COORDINATION_VOLUME_LABEL and reads as coordination rather than a ceiling, so
an agent reading the page cannot reproduce the original false blocker.
Reviewed by Codex and Gemini via the peer-review skill; every finding above
came out of that pass. Cursor could not run -- it needs auth.
---
.gitignore | 3 +
src/app/ipfs2filecoin/constants/migration.ts | 28 +-
src/app/ipfs2filecoin/data/faqs.tsx | 10 +-
src/app/ipfs2filecoin/data/limits.ts | 6 +-
.../ipfs2filecoin/data/migration-runbook.ts | 332 ++++++++++++++++++
src/app/ipfs2filecoin/llms.txt/route.ts | 115 ++----
src/app/ipfs2filecoin/migrate.md/route.ts | 14 +
src/app/ipfs2filecoin/page.tsx | 20 +-
src/app/llms.txt/route.ts | 47 +++
9 files changed, 458 insertions(+), 117 deletions(-)
create mode 100644 src/app/ipfs2filecoin/data/migration-runbook.ts
create mode 100644 src/app/ipfs2filecoin/migrate.md/route.ts
create mode 100644 src/app/llms.txt/route.ts
diff --git a/.gitignore b/.gitignore
index 5ef6a520..4fc5fa81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# Local test fixtures, not part of the site
+dapper-labs-nba-topshots-cids.txt
diff --git a/src/app/ipfs2filecoin/constants/migration.ts b/src/app/ipfs2filecoin/constants/migration.ts
index f226ed63..269fb858 100644
--- a/src/app/ipfs2filecoin/constants/migration.ts
+++ b/src/app/ipfs2filecoin/constants/migration.ts
@@ -1,12 +1,24 @@
import { BASE_URL } from '@/constants/site-metadata'
/**
- * Caps for the browser-based check. Larger inputs are steered to the CLI or to
- * the contact form, because they need capacity agreed with providers up front.
+ * Cap on the in-browser check only. The agent and CLI paths have no item cap,
+ * so this must never be presented as a limit on migration itself.
*/
export const BROWSER_CHECK_ITEM_CAP = 500
+
+/** A hard limit: a larger item cannot be split without changing its CID. */
export const MAX_ITEM_SIZE_LABEL = '1 GiB'
-export const SELF_SERVE_VOLUME_CAP_LABEL = '500 GiB'
+
+/**
+ * Above this, capacity and timing are worth agreeing with providers before a
+ * run starts. A prompt to coordinate, NOT a ceiling on what can be migrated.
+ */
+export const COORDINATION_VOLUME_LABEL = '500 GiB'
+
+/** The ipfs2foc release this runbook's commands and flags were verified against. */
+export const CLI_PACKAGE = 'ipfs2foc'
+export const CLI_VERSION = '0.6.0'
+export const CLI_REPO_URL = 'https://github.com/FilOzone/ipfs2foc'
/** Warm storage list price, per copy. Two copies are stored by default. */
export const USD_PER_TIB_MONTH_PER_COPY = 2.5
@@ -27,11 +39,19 @@ export const DAYS_PER_MONTH = 30
*/
export const BUFFER_DAYS = 30
+/**
+ * The executable runbook. Named for what it is: llms.txt is specified as a
+ * documentation index, not a procedure, so the procedure lives at its own URL.
+ */
+export const RUNBOOK_PATH = '/ipfs2filecoin/migrate.md'
+export const RUNBOOK_URL = `${BASE_URL}${RUNBOOK_PATH}`
+
+/** Spec-conformant index for this campaign, pointing at the runbook. */
export const LLMS_TXT_PATH = '/ipfs2filecoin/llms.txt'
export const LLMS_TXT_URL = `${BASE_URL}${LLMS_TXT_PATH}`
/** The one line a user hands to a coding agent. Copying it is signal, so it is tracked. */
-export const AGENT_PROMPT = `Migrate my IPFS data to Filecoin: read ${LLMS_TXT_URL} and follow it. My CIDs are in cids.txt.`
+export const AGENT_PROMPT = `Migrate my IPFS data to Filecoin: read ${RUNBOOK_URL} and follow it. My CIDs are in cids.txt.`
export const PLAUSIBLE_EVENTS = {
cidListChecked: 'IPFS2Filecoin CID List Checked',
diff --git a/src/app/ipfs2filecoin/data/faqs.tsx b/src/app/ipfs2filecoin/data/faqs.tsx
index 6ff1406b..06449fe1 100644
--- a/src/app/ipfs2filecoin/data/faqs.tsx
+++ b/src/app/ipfs2filecoin/data/faqs.tsx
@@ -4,7 +4,7 @@ import type { Question } from '@/components/Faq'
import { PATHS } from '@/constants/paths'
-import { LLMS_TXT_PATH } from '../constants/migration'
+import { RUNBOOK_PATH } from '../constants/migration'
export const faqs: Array = [
{
@@ -89,10 +89,10 @@ export const faqs: Array = [
<>
All of the work, and none of the spending. The brief at{' '}
- {LLMS_TXT_PATH}{' '}
- gives an agent the command sequence, the caps, and the prerequisites,
- and the tool runs headless with JSON status output so it can read its
- own progress and recover from failures.
+ {RUNBOOK_PATH} gives
+ an agent the command sequence, the caps, and the prerequisites, and
+ the tool runs headless with JSON status output so it can read its own
+ progress and recover from failures.
What it hands back to you are the two steps that move money: funding
diff --git a/src/app/ipfs2filecoin/data/limits.ts b/src/app/ipfs2filecoin/data/limits.ts
index 5f4c863d..d68d01df 100644
--- a/src/app/ipfs2filecoin/data/limits.ts
+++ b/src/app/ipfs2filecoin/data/limits.ts
@@ -1,6 +1,6 @@
import {
+ COORDINATION_VOLUME_LABEL,
MAX_ITEM_SIZE_LABEL,
- SELF_SERVE_VOLUME_CAP_LABEL,
} from '../constants/migration'
export const limits = [
@@ -14,9 +14,9 @@ export const limits = [
description: `Individual assets larger than ${MAX_ITEM_SIZE_LABEL} cannot be moved yet, because splitting them would change their CID and that is the one thing we will not do. Large sets of small items are fine.`,
},
{
- title: `Past ${SELF_SERVE_VOLUME_CAP_LABEL}, talk to us first`,
+ title: `Past ${COORDINATION_VOLUME_LABEL}, talk to us first`,
description:
- 'A migration that size needs capacity and timing agreed with storage providers before it starts. We would rather scope it with you up front than have you discover the ceiling partway through a run.',
+ 'Not a ceiling: there is no cap on how many CIDs an agent or the command line can migrate. But a run that size is worth agreeing with storage providers up front, so capacity and timing are arranged before you start rather than discovered partway through.',
},
{
title: 'Filecoin uses its own identifier underneath',
diff --git a/src/app/ipfs2filecoin/data/migration-runbook.ts b/src/app/ipfs2filecoin/data/migration-runbook.ts
new file mode 100644
index 00000000..db3d54cf
--- /dev/null
+++ b/src/app/ipfs2filecoin/data/migration-runbook.ts
@@ -0,0 +1,332 @@
+import { PATHS } from '@/constants/paths'
+import { BASE_URL } from '@/constants/site-metadata'
+
+import {
+ BUFFER_DAYS,
+ CLI_PACKAGE,
+ CLI_REPO_URL,
+ CLI_VERSION,
+ COORDINATION_VOLUME_LABEL,
+ COPIES,
+ DAYS_PER_MONTH,
+ MAX_ITEM_SIZE_LABEL,
+ RUNBOOK_PATH,
+ USD_LIFECYCLE_RESERVE_PER_DATA_SET,
+ USD_PER_TIB_MONTH_PER_COPY,
+ USD_PROVING_PER_TIB_MONTH_PER_COPY,
+} from '../constants/migration'
+
+const CONTACT_URL = `${BASE_URL}${PATHS.CONTACT.path}`
+const LANDING_URL = `${BASE_URL}${PATHS.IPFS_TO_FILECOIN.path}`
+
+/**
+ * The executable procedure served at {@link RUNBOOK_PATH}. This is the product
+ * for the agent audience, so it names the tool, pins the version its flags were
+ * verified against, and states its stop conditions explicitly. Every claim here
+ * should be checkable against the ipfs2foc README.
+ */
+export const MIGRATION_RUNBOOK = `---
+tool: ${CLI_PACKAGE}
+tool_version: "${CLI_VERSION}"
+tool_repo: ${CLI_REPO_URL}
+node: ">=26"
+default_network: mainnet
+input: a text file of IPFS CIDs, one per line
+item_size_limit: ${MAX_ITEM_SIZE_LABEL}
+item_count_limit: none
+stop_and_ask:
+ - stage 0, before exposing a local port to the internet
+ - stage 4, before any transaction that spends
+never:
+ - re-chunk or alter content
+ - delete data from the existing provider
+ - accept a private key into your context
+---
+
+# Migrate IPFS data to Filecoin — agent runbook
+
+> You are migrating already-pinned IPFS CIDs onto Filecoin warm storage using
+> the \`${CLI_PACKAGE}\` CLI. Each CID stays byte-identical and, on a data set
+> created with IPFS indexing, keeps resolving over IPFS afterwards. This
+> document is the whole procedure. Run these commands. Do not build a
+> commitment or packing pipeline of your own — the on-chain add rejects a
+> mismatched commitment, so an improvised pipeline fails late and expensively.
+
+Verified against \`${CLI_PACKAGE}@${CLI_VERSION}\`. Full command reference:
+${CLI_REPO_URL}
+
+## Rules
+
+1. **Never re-chunk, repackage, or alter content.** An unchanged CID is the
+ point of the migration.
+2. **Never delete data from the current provider.** Verification comes first;
+ deletion is the user's decision afterwards.
+3. **Never take a private key into your context.** Not in chat, not in a file
+ you read. Stage 4 is executed by the user in their own terminal.
+4. **Stop and ask at the two gates below.** Exposing a local port (stage 0) and
+ spending (stage 4) are the user's decisions.
+5. **The deposit is the ceiling.** A key that can sign on the account can draw
+ against its balance. Advise a wallet kept for this purpose, holding what this
+ migration needs and nothing else.
+6. **Report failures with counts and names.** Never silently drop a CID.
+
+## Setup
+
+Install once, then every command below runs as \`${CLI_PACKAGE} ...\`:
+
+\`\`\`bash
+npm install -g ${CLI_PACKAGE}@${CLI_VERSION}
+${CLI_PACKAGE} --version
+\`\`\`
+
+If you cannot install globally, prefix **every** command in this document with
+\`npx -y ${CLI_PACKAGE}@${CLI_VERSION}\` instead. Do not mix the two forms: a bare
+\`${CLI_PACKAGE}\` after only an \`npx\` check is the most common first failure.
+
+Set the network once and pass it to every on-chain command:
+
+\`\`\`bash
+NETWORK=mainnet # or: NETWORK=calibration, to rehearse on the testnet
+\`\`\`
+
+Rehearsing the whole flow on calibration first is strongly recommended before
+spending real funds.
+
+### Needed now (stages 0-3)
+
+- Node 26 or newer. Hard requirement. Check with \`node --version\`.
+- A CID list, one per line. Blank lines and \`#\` comments are ignored.
+- A trustless gateway that serves deterministic CARs. Stage 1 verifies this.
+
+### Needed before stage 4, not before
+
+- A wallet holding USDFC for storage and FIL for gas. **The user holds this
+ key. You never see it.**
+- A provider id from \`https://pdp.vxb.ai/\${NETWORK}/providers\`.
+
+Do not block stages 0-3 on the wallet. Those stages are free and need no key.
+
+## Stage 0 — settle ingress before computing anything
+
+At submission the storage provider pulls each piece from a public HTTPS origin.
+Private, loopback, and CGNAT addresses are rejected. **Resolve this first.**
+Discovering it after the commitment pass wastes that entire pass, which on a
+large list is hours of work.
+
+| Option | Flag | Requires | Works with |
+| --- | --- | --- | --- |
+| Cloudflare quick tunnel | \`--ingress cloudflared\` | \`cloudflared\` on PATH, no account | any path |
+| Tailscale Funnel or a VPS | \`--ingress funnel\` | you front the port yourself | any path |
+| Shared relay | \`--source-relay \` | a relay base URL | single-asset path only |
+
+\`\`\`bash
+command -v cloudflared
+\`\`\`
+
+The relay is **only** valid for the single-asset path. If stage 2 sends you to
+the multi-asset path, the provider must pull assembled CAR files from your own
+origin, and the relay cannot serve them.
+
+**Gate.** If no option is available, stop and tell the user. A tunnel exposes a
+local port to the internet; that is their decision to make, not yours. Do not
+install a tunnel binary on their behalf.
+
+## Stage 1 — confirm the gateway serves deterministic CARs
+
+\`\`\`bash
+${CLI_PACKAGE} probe --gateway https://trustless-gateway.link
+\`\`\`
+
+\`WARN\` means the bytes did not re-hash to the requested CID, or the response was
+not a CAR. That gateway cannot be a source. Try another before continuing; the
+repo's \`docs/sources.md\` lists per-provider notes. Do not proceed on a \`WARN\`:
+the provider re-fetches and recomputes, so a non-deterministic source fails
+every piece.
+
+## Stage 2 — measure, then tell the user what they are in for
+
+Free, read-only, no wallet.
+
+\`\`\`bash
+${CLI_PACKAGE} analyze --cids cids.txt --sample 100 --json
+\`\`\`
+
+Use \`--all\` instead of \`--sample 100\` only when the list is small enough that
+probing every CID is affordable; it is the only way to get an exact per-CID
+result at this stage.
+
+The JSON gives you \`input.totalCount\`, \`input.estimatedTotalSizeBytes\`,
+\`sourceGateway.successRate\`, \`sourceGateway.latencyP50Ms\`, per-probe \`bytes\`,
+and a \`persona\` block of recommended flags. It does **not** give you a cost or a
+completion time. Derive those as below; do not invent a formula.
+
+### What to report back before going further
+
+- CID count, and the sampled retrieval success rate. If you sampled, say so —
+ you know which CIDs failed **in the sample**, not across the whole list. The
+ exact per-CID result comes out of stage 3.
+- Projected total size, from \`estimatedTotalSizeBytes\`.
+- **Projected wall-clock time.** A rough lower bound for the commitment pass is
+ \`totalCount × latencyP50Ms ÷ concurrency\`. State it as approximate, and say
+ that the provider's pull rate — not this pass — dominates the total for a
+ large run.
+- Estimated storage cost, using the formula below.
+
+### Cost formula
+
+Storage is ${USD_PER_TIB_MONTH_PER_COPY} USDFC per TiB per month per copy plus
+${USD_PROVING_PER_TIB_MONTH_PER_COPY} proving, across ${COPIES} copies by default:
+
+\`\`\`
+burn_per_TiB_month = (${USD_PER_TIB_MONTH_PER_COPY} + ${USD_PROVING_PER_TIB_MONTH_PER_COPY}) × ${COPIES}
+storage = size_TiB × burn_per_TiB_month × (days_funded ÷ ${DAYS_PER_MONTH})
+buffer = size_TiB × burn_per_TiB_month # ${BUFFER_DAYS} days, refundable
+reserve = ${USD_LIFECYCLE_RESERVE_PER_DATA_SET} × ${COPIES} # per data set, unused portion returns
+deposit = storage + buffer + reserve
+\`\`\`
+
+Gas is paid separately in FIL. A deposit funds a rate, not a term: storing more
+data later spends the balance faster and moves the funded-until date earlier.
+Treat these as estimates; the exact deposit is shown at funding time.
+
+### Long runs are normal, not a limit
+
+**There is no cap on how many CIDs this path can migrate.** A large list simply
+takes a long time — often many hours. Say so plainly and let the user decide.
+Do not treat duration as a blocker, and do not route a large list away on size
+alone.
+
+- A command that runs for hours is working, not hung. Do not kill it. State is
+ kept in SQLite and a stopped run resumes where it left off.
+- Run on a machine that stays awake. A sleeping laptop stalls the run.
+- Use the flags \`analyze\` recommends in its \`persona\` block rather than guessing.
+
+Above roughly ${COORDINATION_VOLUME_LABEL}, capacity and timing are worth
+agreeing with providers before starting. That is coordination, not a ceiling:
+mention ${CONTACT_URL} as an option and continue if the user wants to.
+
+### Choosing a packing path
+
+Providers enforce a **minimum piece size** (commonly 1 MiB) and a pull limit of
+roughly ${MAX_ITEM_SIZE_LABEL} raw per item. Read the actual values for your
+chosen provider from \`https://pdp.vxb.ai/\${NETWORK}/providers\` before deciding.
+
+Evaluate against the per-probe \`bytes\` in the \`analyze\` JSON:
+
+- **Few sampled items below the provider minimum** → single-asset path
+ (the default). Each CID becomes one passthrough item pulled straight from the
+ gateway. No staging disk needed.
+- **A meaningful share below the minimum** → multi-asset path, or those CIDs
+ fail at pull time. Needs disk for assembled CARs, and rules out the relay.
+- **Any item above the pull limit** → cannot be migrated; splitting it would
+ change its CID. Hold those out and report them to the user.
+
+## Stage 3 — plan
+
+\`\`\`bash
+# single-asset (default)
+${CLI_PACKAGE} plan --cids cids.txt --db migrate.db
+
+# multi-asset, when many items fall below the provider minimum
+${CLI_PACKAGE} plan --cids cids.txt --db migrate.db --no-auto-pack
+${CLI_PACKAGE} pack-cars --db migrate.db --car-store ./cars --pack-target-size 512MiB
+\`\`\`
+
+\`plan\` is insert-only and resumable: re-running it computes only CIDs not yet
+done and never disturbs committed state. This stage produces the **exact**
+per-CID result, including anything unretrievable or \`oversized\`. Report that to
+the user; it supersedes the stage 2 sample.
+
+Check progress at any time with \`${CLI_PACKAGE} status --json\`.
+
+## Stage 4 — funding (the user runs this, not you)
+
+**Gate. This stage spends money and requires a private key.**
+
+Stop. Give the user these commands to run **in their own terminal**, and ask
+them to paste back only the printed \`dataSetId\`. Do not offer to run these for
+them, and do not accept a key if one is offered — a key pasted into a chat is a
+key in a transcript.
+
+\`\`\`bash
+export PRIVATE_KEY=0x... # user's terminal only
+npx filecoin-pin@latest payments setup --auto --network "$NETWORK"
+npx filecoin-pin@latest payments status --network "$NETWORK"
+${CLI_PACKAGE} create-data-set --provider-id --network "$NETWORK"
+\`\`\`
+
+\`create-data-set\` reverts if the USDFC deposit or the operator approval is
+insufficient. Remind the user that the deposited amount is the ceiling on
+everything downstream.
+
+**Resume only when the user confirms funding succeeded and gives you the
+\`dataSetId\`.** Submission in stage 5 also signs, so it needs the same key
+available in the environment where it runs. If the user wants to keep the key
+off your machine entirely, stage 5 is theirs to run too, and you read back the
+report from stage 6.
+
+## Stage 5 — submit
+
+Two processes. The pull source must stay running for the whole submission.
+
+\`\`\`bash
+# Start in the background and read its log until it prints the public URL.
+${CLI_PACKAGE} redirect-serve --db migrate.db --port 4322 --ingress cloudflared
+# logs: "cloudflared ingress: ready at https://.trycloudflare.com"
+\`\`\`
+
+Extract that HTTPS origin from the log output and pass it as \`--source-base\`.
+It is the **origin only** — scheme and host, no path. Then:
+
+\`\`\`bash
+${CLI_PACKAGE} pdp-submit --db migrate.db --data-set-id \\
+ --source-base https:// --network "$NETWORK"
+\`\`\`
+
+With a relay instead of your own ingress (single-asset path only):
+
+\`\`\`bash
+${CLI_PACKAGE} pdp-submit --db migrate.db --data-set-id \\
+ --source-relay --network "$NETWORK"
+\`\`\`
+
+\`pdp-submit\` is resumable and will not double-submit. It pauses when the network
+base fee is above \`--max-base-fee\`; that is congestion backoff, not a failure.
+Check with \`${CLI_PACKAGE} gas --network "$NETWORK"\`.
+
+## Stage 6 — verify
+
+\`\`\`bash
+${CLI_PACKAGE} report --db migrate.db --data-set-id --network "$NETWORK" --json
+\`\`\`
+
+This reconciles local state against the pieces the data set actually holds on
+chain. Hand the user the report, the data set id, and the path to \`migrate.db\`.
+
+Tell them explicitly: **check retrieval of a few of their own CIDs, and keep the
+old pinning plan until they have.** Verification is theirs to accept, not yours
+to declare.
+
+## Failure modes
+
+| Symptom | Meaning | Action |
+| --- | --- | --- |
+| \`probe\` reports \`WARN\` | gateway does not serve deterministic CARs | pick another gateway |
+| \`plan\` reports \`oversized\` | padded size exceeds the aggregate budget | hold that CID out, report it |
+| \`below provider min piece size\` | items under the minimum on the passthrough path | switch to the multi-asset path |
+| provider rejects the pull | \`--source-base\` has a path, or is not a public IP | pass origin only, recheck ingress |
+| submission pauses on \`spike\` | base fee above the gate | wait it out; this is expected |
+| \`set PRIVATE_KEY\` error | key not in that shell's environment | the user exports it in their own terminal |
+| aggregate stuck \`failed\` | see the repo's recovery commands | \`reset-failed-aggregates\` |
+
+One unretrievable item fails everything batched with it, which is why stages 2
+and 3 validate retrievability before anything is submitted.
+
+## Links
+
+- Tool, full command reference, and troubleshooting: ${CLI_REPO_URL}
+- Rehearse on the testnet first: \`docs/tutorial-first-migration.md\` in that repo
+- Landing page: ${LANDING_URL}
+- Talk to the team about capacity: ${CONTACT_URL}
+- This runbook: ${BASE_URL}${RUNBOOK_PATH}
+`
diff --git a/src/app/ipfs2filecoin/llms.txt/route.ts b/src/app/ipfs2filecoin/llms.txt/route.ts
index 5f57935c..b1029a1e 100644
--- a/src/app/ipfs2filecoin/llms.txt/route.ts
+++ b/src/app/ipfs2filecoin/llms.txt/route.ts
@@ -2,113 +2,40 @@ import { PATHS } from '@/constants/paths'
import { BASE_URL, FOC_URLS } from '@/constants/site-metadata'
import {
- BROWSER_CHECK_ITEM_CAP,
- COPIES,
+ CLI_REPO_URL,
MAX_ITEM_SIZE_LABEL,
- SELF_SERVE_VOLUME_CAP_LABEL,
- USD_PER_TIB_MONTH_PER_COPY,
+ RUNBOOK_PATH,
} from '../constants/migration'
/**
- * Placeholder migration brief. The full command sequence lives with the tool
- * that runs it, in FilOzone/ipfs2foc, and replaces the body of this route once
- * it is published. Serving it from here now means the prompt on the landing
- * page resolves and we can see who is fetching it.
+ * Spec-conformant llms.txt for this campaign: an H1, a blockquote summary, then
+ * H2 lists of links. Per llmstxt.org this format is a documentation index, not
+ * a procedure, so the procedure lives at migrate.md and this file points to it.
*/
-const LLMS_TXT = `# IPFS to Filecoin — migration brief
+const LLMS_TXT = `# IPFS to Filecoin
-> DRAFT. This brief is published ahead of the full command sequence. It states
-> what the migration does, what it costs, and where it stops. Do not infer
-> commands that are not listed here; check the tool's README instead.
+> Move already-pinned IPFS data onto Filecoin warm storage without changing your
+> CIDs. Each CID stays byte-identical and keeps resolving over IPFS. Storage
+> providers prove possession on chain on a schedule.
-## What this is
+If you are an agent asked to perform a migration, read the runbook first: it is
+the complete procedure, and the rest of these links are background.
-A migration of pinned IPFS data into Filecoin warm storage. Content is stored as
-the CAR of the original DAG, so every CID stays byte-identical and keeps
-resolving from any public IPFS gateway. Storage providers prove possession
-onchain on a schedule.
+## Do the migration
-## Prerequisites
+- [Migration runbook](${BASE_URL}${RUNBOOK_PATH}): the executable procedure — prerequisites, ordered command sequence, stop conditions, and failure modes. Start here.
+- [ipfs2foc](${CLI_REPO_URL}): the CLI the runbook drives, with the full command reference and troubleshooting.
-- Node.js 26 or newer. This is a hard requirement.
-- A list of CIDs, one per line, in a text file (conventionally cids.txt).
-- Each CID must be retrievable from a public IPFS gateway. Private pinning
- accounts and authenticated endpoints are not reachable this way.
-- A wallet holding USDFC for storage and a small amount of FIL for gas.
- Use a wallet kept for this purpose. The deposited balance is the ceiling on
- what any unattended run can spend.
-- A public HTTPS origin the storage provider can pull pieces from. See stage 0.
+## Background
-## Stage 0 — check the ingress before anything else
+- [IPFS to Filecoin](${BASE_URL}${PATHS.IPFS_TO_FILECOIN.path}): what this is, what it costs, and what it does not do. Includes a free in-browser CID check.
+- [Warm storage service](${BASE_URL}${PATHS.WARM_STORAGE_SERVICE.path}): the storage product underneath, and its pricing.
+- [Service providers](${BASE_URL}${PATHS.SERVICE_PROVIDERS.path}): the providers that store and prove the data.
+- [Documentation](${FOC_URLS.documentation.home}): Filecoin Onchain Cloud docs.
-At submission a storage provider pulls pieces from a public HTTPS origin that
-you supply. Private and loopback addresses are rejected. Confirm you have one
-of these BEFORE computing piece commitments, because discovering it afterwards
-wastes the entire run:
+## Limits
-- the \`cloudflared\` binary installed, for \`--ingress cloudflared\`; or
-- a VPS or Tailscale fronting the port yourself, for \`--ingress funnel\`; or
-- a source relay URL, for \`--source-relay\`.
-
-If none is available, stop and report that to the user rather than choosing one
-on their behalf. Installing a tunnel binary and exposing a local port to the
-internet is the user's decision to make.
-
-## Caps
-
-- ${MAX_ITEM_SIZE_LABEL} per item. Larger assets cannot be moved yet, because
- splitting them would change their CID.
-- ${SELF_SERVE_VOLUME_CAP_LABEL} total for a self-serve run. Past that, capacity
- and timing must be agreed with storage providers first: ${BASE_URL}${PATHS.CONTACT.path}
-- ${BROWSER_CHECK_ITEM_CAP} items for the in-browser check on the landing page.
- The command-line tool has no item cap and resumes if a run stops.
-
-## Cost
-
-Storage is ${USD_PER_TIB_MONTH_PER_COPY} USDFC per TiB per month per copy, at
-${COPIES} copies by default, streamed to providers per epoch. A small proving
-charge is billed alongside it. Funding is a deposit covering a chosen period,
-plus a refundable 30-day buffer and a per-data-set lifecycle reserve held while
-the data is stored. Gas is paid separately in FIL.
-
-A deposit funds a rate, not a term. Funded duration is the available balance
-divided by the burn rate across every data set on the account, so storing more
-data later moves the funded-until date earlier.
-
-## Stages
-
-1. analyze — read the CID list, fetch each CID from a public gateway, compute
- its Filecoin piece identifier and size. Free, no wallet, nothing charged.
-2. plan — produce the deposit required for the chosen retention period, and the
- list of CIDs that could not be retrieved.
-3. fund — deposit USDFC and approve spending. This step moves money and is
- handed back to the human.
-4. migrate — providers pull the data and commit it onchain.
-5. verify — retrieve by original CID and check the onchain proof, then keep the
- manifest mapping every IPFS CID to its Filecoin piece identifier.
-
-## Failure modes
-
-- Unreachable CIDs are reported with counts and recorded in the manifest. They
- are never silently dropped.
-- A stopped run resumes from its recorded state. Nothing already stored is
- re-paid for.
-- If the funded balance runs out, providers can end the service and keep the
- buffer. Top up before the funded-until date.
-
-## Do not
-
-- Re-chunk, repackage, or otherwise alter content. Changing a CID defeats the
- point of the migration.
-- Delete data from the existing pinning provider until the migration has been
- verified.
-- Use a key to a wallet that holds anything beyond what this migration needs.
-
-## Links
-
-- Landing page: ${BASE_URL}${PATHS.IPFS_TO_FILECOIN.path}
-- Documentation: ${FOC_URLS.documentation.home}
-- Talk to our team: ${BASE_URL}${PATHS.CONTACT.path}
+- [Talk to our team](${BASE_URL}${PATHS.CONTACT.path}): for sources that are not reachable from a public gateway, or to agree capacity and timing before a large run. There is no cap on how many CIDs the agent or CLI path can migrate; individual items above ${MAX_ITEM_SIZE_LABEL} cannot be migrated, because splitting them would change their CID.
`
export function GET() {
diff --git a/src/app/ipfs2filecoin/migrate.md/route.ts b/src/app/ipfs2filecoin/migrate.md/route.ts
new file mode 100644
index 00000000..489a136a
--- /dev/null
+++ b/src/app/ipfs2filecoin/migrate.md/route.ts
@@ -0,0 +1,14 @@
+import { MIGRATION_RUNBOOK } from '../data/migration-runbook'
+
+/**
+ * The executable runbook an agent follows end to end. Served as markdown so an
+ * agent that fetches it gets the procedure verbatim rather than a rendered page.
+ */
+export function GET() {
+ return new Response(MIGRATION_RUNBOOK, {
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Cache-Control': 'public, max-age=0, s-maxage=3600',
+ },
+ })
+}
diff --git a/src/app/ipfs2filecoin/page.tsx b/src/app/ipfs2filecoin/page.tsx
index 3a1ed035..439d7521 100644
--- a/src/app/ipfs2filecoin/page.tsx
+++ b/src/app/ipfs2filecoin/page.tsx
@@ -22,10 +22,7 @@ import { AgentPrompt } from './components/AgentPrompt'
import { CidListChecker } from './components/CidListChecker'
import { ComparisonTable } from './components/ComparisonTable'
import { CostEstimator } from './components/CostEstimator'
-import {
- LLMS_TXT_PATH,
- SELF_SERVE_VOLUME_CAP_LABEL,
-} from './constants/migration'
+import { COORDINATION_VOLUME_LABEL, RUNBOOK_PATH } from './constants/migration'
import { IPFS2FILECOIN_SEO } from './constants/seo'
import { faqs } from './data/faqs'
import { limits } from './data/limits'
@@ -154,9 +151,9 @@ export default function IpfsToFilecoin() {
Give this line to Claude Code, Cursor, or any coding agent. It
- reads the migration brief at{' '}
-
- {LLMS_TXT_PATH}
+ reads the runbook at{' '}
+
+ {RUNBOOK_PATH}
, works through your list, and reports back what landed.
@@ -175,9 +172,10 @@ export default function IpfsToFilecoin() {
Talk to us
- For datasets over {SELF_SERVE_VOLUME_CAP_LABEL}, or sources that
- are not reachable from a public gateway. Tell us what you are
- holding and we will scope it with you.
+ For sources that are not reachable from a public gateway, or to
+ agree capacity and timing before a run over{' '}
+ {COORDINATION_VOLUME_LABEL}. That is coordination, not a
+ ceiling: the agent path has no cap on how many CIDs it migrates.