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 } /** * 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. */ export async function publishIPNSRecord( privateKey: Uint8Array, cid: CID, targets: IPNSPublishTarget[], ): Promise { const ipnsName = ipnsNameFromPrivateKey(privateKey) const sequence = await pickNextSequence(ipnsName, targets) const signed = await createSignedIPNSRecord(privateKey, cid, sequence) const publishTargets = targets.filter(t => typeof t.publish === 'function') if (publishTargets.length === 0) { throw new Error('No publish-capable targets supplied to publishIPNSRecord') } 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 }[] if (failures.length > 0 && failures.length < publishTargets.length) { // Partial failure — log but don't throw for (const { r, name } of failures) { console.warn(`[publishIPNSRecord] target '${name}' failed:`, r.reason) } } else if (failures.length === publishTargets.length) { throw new Error(`All IPNS publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`) } return signed } /** * 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 }