import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns' import { privateKeyFromRaw } from '@libp2p/crypto/keys' import { base36 } from 'multiformats/bases/base36' import { base64pad } from 'multiformats/bases/base64' import type { CID } from 'multiformats/cid' export interface SignedIPNSRecord { recordBytes: Uint8Array // marshalled protobuf, signed — the wire format ipnsName: string // k51... string value: string // /ipfs/ sequence: bigint } /** Derive IPNS name string (k51...) from Ed25519 private key bytes */ export function ipnsNameFromPrivateKey(privKeyBytes: Uint8Array): string { const privKey = privateKeyFromRaw(privKeyBytes) return privKey.publicKey.toCID().toString(base36) } /** * Create a signed IPNS record (protobuf wire format). * Same bytes can be published to any naming service. */ export async function createSignedIPNSRecord( privateKey: Uint8Array, cid: CID, sequence: bigint, lifetimeMs = 365 * 24 * 60 * 60 * 1000, // 1 year ): Promise { const privKey = privateKeyFromRaw(privateKey) const value = `/ipfs/${cid.toV1()}` const record = await createIPNSRecord(privKey, value, sequence, lifetimeMs) const recordBytes = marshalIPNSRecord(record) const ipnsName = privKey.publicKey.toCID().toString(base36) return { recordBytes, ipnsName, value, sequence } } export { unmarshalIPNSRecord } /** * A target that can receive a signed IPNS record, advertise its current * sequence, or both. * * - `publish` is called by `publishIPNSRecord` to actually store the record * on the target's backing service. Targets without `publish` are skipped * during the fan-out (e.g. a sequence-only source). * - `resolveSequence` is consulted by `publishIPNSRecord` to compute the * next IPNS sequence. The first target to return a value (including * `null` for "never published") wins. Throw to indicate a transient * error — the caller will try the next target. * * Most real targets (e.g. a storage connector) provide both. A simple * "track sequence in localStorage" target only needs `resolveSequence`. */ export interface IPNSPublishTarget { name: string publish?(ipnsName: string, recordBytes: Uint8Array): Promise resolveSequence?(ipnsName: string): Promise } /** * Resolve the current IPNS sequence from a generic naming service. * Returns null if the name was never published (404). * Throws on network/server errors so the caller can try a different target. */ export async function resolveIPNSSequence( nameServiceUrl: string, ipnsName: string, ): Promise { const url = `${nameServiceUrl}/name/${ipnsName}` let response: Response try { response = await fetch(url) } catch (err) { throw new Error(`Network error resolving IPNS ${ipnsName}: ${err}`) } if (response.status === 404) return null if (!response.ok) { throw new Error(`HTTP ${response.status} resolving IPNS ${ipnsName}: ${response.statusText}`) } const { record, value } = await response.json() if (!record && !value) return null // never published // If raw record is available, unmarshal to get sequence if (record) { const bytes = base64pad.baseDecode(record) const entry = unmarshalIPNSRecord(bytes) return entry.sequence } // Server only returned a value, no raw record — can't know the exact sequence. // Most services still accept this as "previous published"; treat as seq=0n. return 0n } /** Regex to detect Kubo IPNS sequence conflict in error messages */ const SEQ_CONFLICT_RE = /existing IPNS record has sequence (\d+) >= new record sequence (\d+)/ /** * Options for {@link publishIPNSRecord}. */ export interface PublishIPNSRecordOptions { /** * Max number of times to bump the sequence on "existing >= new" conflict. * Defaults to 3. Each bump re-signs the record with a higher sequence * and retries all targets. */ maxSequenceBumps?: number } /** * Create a signed IPNS record and publish to all configured targets. * * Sequence resolution: walks `targets` in order asking each one with a * `resolveSequence` method. The first target to successfully return a value * (including `null` for "never published") determines the next sequence. * If every target throws or none supports `resolveSequence`, falls back to 0n. * * Publish fan-out: only targets with a `publish` method are called. * Throws if every publish-capable target fails; partial failures are logged. * * **Sequence-coherence retry:** if a publish target rejects with the Kubo * "existing IPNS record has sequence X >= new record sequence Y" error, * the function parses `X`, bumps the sequence to `max(X + 1n, Y + 1n)`, * re-signs the record, and retries (up to `maxSequenceBumps` times). * This is the "increment and clobber" mechanism that recovers from * sequence drift without requiring server-side `--force` support. */ export async function publishIPNSRecord( privateKey: Uint8Array, cid: CID, targets: IPNSPublishTarget[], options?: PublishIPNSRecordOptions, ): Promise { const ipnsName = ipnsNameFromPrivateKey(privateKey) const maxBumps = options?.maxSequenceBumps ?? 3 const publishTargets = targets.filter(t => typeof t.publish === 'function') if (publishTargets.length === 0) { throw new Error('No publish-capable targets supplied to publishIPNSRecord') } let sequence: bigint | undefined let bumpFloor: bigint | undefined for (let attempt = 0; ; attempt++) { // Compute next sequence on first pass or after a conflict bump if (sequence === undefined) { sequence = await pickNextSequence(ipnsName, targets) } if (bumpFloor !== undefined) { // bumpFloor was set on previous attempt from a conflict error if (bumpFloor > sequence) sequence = bumpFloor bumpFloor = undefined } const signed = await createSignedIPNSRecord(privateKey, cid, sequence) const results = await Promise.allSettled( publishTargets.map(t => t.publish!(ipnsName, signed.recordBytes)), ) const failures = results .map((r, i) => ({ r, name: publishTargets[i].name })) .filter(({ r }) => r.status === 'rejected') as { r: PromiseRejectedResult; name: string }[] // All succeeded — done if (failures.length === 0) { return signed } // Check if any failure is a sequence conflict we can recover from const conflictReasons = failures.filter(({ r }) => SEQ_CONFLICT_RE.test(typeof r.reason === 'string' ? r.reason : String(r.reason)), ) if (conflictReasons.length > 0 && attempt < maxBumps) { // Parse the highest existing sequence from each conflict error let bump = sequence for (const { r } of conflictReasons) { const msg = typeof r.reason === 'string' ? r.reason : String(r.reason) const m = msg.match(SEQ_CONFLICT_RE) if (m) { const existing = BigInt(m[1]) if (existing >= bump) bump = existing + 1n } } if (bump > sequence) { console.warn( `[publishIPNSRecord] Sequence conflict: server seq ${bump - 1n} >= current ${sequence}, ` + `bumping to ${bump} (attempt ${attempt + 1}/${maxBumps})`, ) sequence = bump continue } } // Partial failure (some targets succeeded) — log and return the signed record if (failures.length < publishTargets.length) { for (const { r, name } of failures) { console.warn(`[publishIPNSRecord] target '${name}' failed:`, r.reason) } return signed } // Every publish-capable target failed and we cannot recover if (conflictReasons.length > 0) { throw new Error( `IPNS sequence conflict not resolved after ${Math.min(attempt + 1, maxBumps)} bumps. ` + `All publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`, ) } throw new Error( `All IPNS publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`, ) } } /** * Walk targets, asking each to resolve the current IPNS sequence. First success wins. * Falls back to 0n if no target can answer. */ async function pickNextSequence( ipnsName: string, targets: IPNSPublishTarget[], ): Promise { const capable = targets.filter(t => typeof t.resolveSequence === 'function') if (capable.length === 0) { return 0n } for (const target of capable) { try { const current = await target.resolveSequence!(ipnsName) return current == null ? 0n : current + 1n } catch (err) { console.warn(`[publishIPNSRecord] target '${target.name}' sequence resolve failed:`, err) } } console.warn(`[publishIPNSRecord] no target could resolve sequence for ${ipnsName} — starting at 0n`) return 0n }