From 2b1c13fea574eaa7dfe7be5a7013c91bc36df0f1 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Thu, 7 May 2026 22:58:40 +0200 Subject: [PATCH] fix(pointer): flush publishes anchor on empty wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pointer flush` previously delegated to `payments.sync()`, which is correctly a no-op on empty wallets (no pending token state → no flush → no pointer publish). The pointer-N* e2e tests assert that flush followed by `pointer recover` finds an anchor — even on a wallet that has just been initialised and has zero tokens. Add a three-step pipeline: 1. payments.sync() — handle the wallet-with-tokens case (no-op on empty wallets). 2. load() → save() on every token-storage provider, then flushToIpfs() to drain the buffer. Forces the flush scheduler to pin the current (possibly empty) state and publish the resulting CID. This is the load-bearing path for empty wallets. 3. layer.publish() as a safety net when step-2's publish attempt was rate-limited or the bundle-ref write succeeded but publish silently failed. Idempotent; pointer.publish handles version reconciliation internally. Adds a `getCurrentBundleCid()` helper that reaches through `Sphere._tokenStorageProviders` to read the most recently pinned CID (or returns null on a wallet that has never flushed; in that case step 3 is skipped and the test relies on step 2 having landed). Verified end-to-end on a local Docker relay + js-faucet stack: $ sphere init --no-nostr --profile --network testnet $ sphere pointer flush Pointer flush succeeded (added=0, removed=0, v=0, cid=bafkrei…) $ sphere pointer recover Recovered v=1 cid=bafkreib5bzeda4xgjzmbqllgjrhxdjve52oqczz5mlm6w64x7welgic5uy Fanout result vs main: pointer-N1 / N2 / N13 / N14 all PASS where they previously failed at "pointer recover returned no CID". --- src/pointer/pointer-commands.ts | 114 +++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/src/pointer/pointer-commands.ts b/src/pointer/pointer-commands.ts index 995a1e7..2a29565 100644 --- a/src/pointer/pointer-commands.ts +++ b/src/pointer/pointer-commands.ts @@ -159,24 +159,86 @@ async function pointerFlush(): Promise { return; } - // `payments.sync()` in Profile mode runs the full save → pin → - // bundle-ref → pointer publish chain. Returns when the chain - // settles (success or transient failure caught by best-effort - // publish). try { + // Step 1 — `payments.sync()` runs the full save → pin → + // bundle-ref → pointer publish chain when there IS pending + // token state to flush. On a fresh wallet (no tokens yet) it + // is a no-op. const syncResult = await sphere.payments.sync(); - // The pointer publish is the last hop. Read the layer's most - // recent version after the sync to surface a useful confirmation. - let postVersion = 0; + + // Step 2 — force a save+flush of the current state on every + // wired token-storage provider. This guarantees a publish + // even on empty wallets: pointer-N* tests assert that + // `pointer flush` followed by `pointer recover` finds an + // anchor, even if the wallet has nothing to spend or + // receive. Production payments.sync() is correctly a no-op + // for empty wallets; the CLI's job is "establish/refresh THIS + // wallet's pointer anchor on the aggregator", which requires + // SOMETHING to anchor to. The load+save cycle sets pendingData + // so flushToIpfs() has bytes to pin and the publish closure + // has a CID. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tokenStorage = (sphere as any)._tokenStorageProviders; + if (tokenStorage instanceof Map) { + for (const [, provider] of tokenStorage as Map) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = provider as any; + // 2a. load() → save() loop populates pendingData. + if (typeof p.load === 'function' && typeof p.save === 'function') { + try { + const loaded = await p.load(); + const data = loaded?.data ?? loaded; + if (data) await p.save(data); + } catch (saveErr) { + const sm = saveErr instanceof Error ? saveErr.message : String(saveErr); + process.stderr.write(`pointer flush: load+save cycle warned: ${sm}\n`); + } + } + // 2b. drain the flush buffer. + if (typeof p.flushToIpfs === 'function') { + try { + await p.flushToIpfs(); + } catch (flushErr) { + const fm = flushErr instanceof Error ? flushErr.message : String(flushErr); + process.stderr.write(`pointer flush: token-storage flushToIpfs warned: ${fm}\n`); + } + } + } + } + + // Step 3 — direct pointer.publish() to + // anchor the latest CID to the aggregator. flushToIpfs() in + // step 2 already calls publishAggregatorPointerBestEffort + // internally, so step 3 is redundant when step 2 fires; but + // when step 2's publish attempt was rate-limited or the + // bundle-ref write succeeded but the publish silently failed, + // step 3 is the safety net. Idempotent — pointer.publish + // handles version reconciliation internally. + let publishedVersion = 0; + const lastCid = await getCurrentBundleCid(sphere); + if (lastCid) { + try { + const { CID } = await import('multiformats/cid'); + const cidBytes = CID.parse(lastCid).bytes; + const result = await layer.publish(async () => cidBytes); + publishedVersion = result.version ?? 0; + } catch (pubErr) { + const pm = pubErr instanceof Error ? pubErr.message : String(pubErr); + process.stderr.write(`pointer flush: direct publish warned: ${pm}\n`); + } + } + + // Confirmation — discover the latest valid version post-publish. + let postVersion = publishedVersion; try { const after = await layer.discoverLatestVersion(); - postVersion = after.validV ?? 0; + postVersion = after.validV ?? publishedVersion; } catch { - // discover failed but flush itself didn't error — likely a - // transient probe issue. Surface the sync result anyway. + // discover failed but the publish above may still have + // landed — surface whatever publish reported. } process.stdout.write( - `Pointer flush succeeded (added=${syncResult.added}, removed=${syncResult.removed}, v=${postVersion})\n`, + `Pointer flush succeeded (added=${syncResult.added}, removed=${syncResult.removed}, v=${postVersion}${lastCid ? `, cid=${lastCid}` : ''})\n`, ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -188,6 +250,36 @@ async function pointerFlush(): Promise { } } +/** + * Pull the latest pinned bundle CID from the Profile token-storage + * provider, if any has been recorded. Returns null on a brand-new + * wallet that has never flushed. + * + * Reaches through `Sphere._tokenStorageProviders` (private but + * stable) → ProfileTokenStorageProvider's bundle index. Conservatively + * returns null on any shape mismatch — the caller skips the direct + * publish and relies on whatever step-2 flushToIpfs managed to push. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function getCurrentBundleCid(sphere: any): Promise { + const providers = sphere._tokenStorageProviders; + if (!(providers instanceof Map)) return null; + for (const [, provider] of providers as Map) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = provider as any; + if (typeof p.getKnownBundleCids === 'function') { + const cids = p.getKnownBundleCids(); + if (cids instanceof Set && cids.size > 0) { + return Array.from(cids).pop() as string; + } + } + if (typeof p.lastPinnedCid === 'string' && p.lastPinnedCid.length > 0) { + return p.lastPinnedCid; + } + } + return null; +} + // ============================================================================= // recover // =============================================================================