diff --git a/src/components/boost/boost-button.tsx b/src/components/boost/boost-button.tsx
index 557487c..ee362c2 100644
--- a/src/components/boost/boost-button.tsx
+++ b/src/components/boost/boost-button.tsx
@@ -13,13 +13,23 @@ const DEFAULT_BOOST_AMOUNT = 10
interface BoostButtonProps {
refId: string
- pubkey: string
+ ownerReference: string
+ /** Optional — only used for the admin direct-keysend path. After phase-4d this
+ * field disappears from the API; admin boosts then fall through to /boost. */
+ pubkey?: string
routeHint?: string
boostCount?: number
className?: string
}
-export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, className }: BoostButtonProps) {
+export function BoostButton({
+ refId,
+ ownerReference,
+ pubkey,
+ routeHint,
+ boostCount = 0,
+ className,
+}: BoostButtonProps) {
const [count, setCount] = useState(boostCount)
const [boosting, setBoosting] = useState(false)
const [flash, setFlash] = useState(false)
@@ -34,26 +44,26 @@ export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, classNam
setBoosting(true)
setError(null)
- // Prefer separate pubkey + route_hint props (new split storage);
- // fall back to parsing a compound pubkey for legacy callers.
- const dest = routeHint ? { pubkey, route_hint: routeHint } : parsePubkeyWithHint(pubkey)
- console.log("[boost] parsed dest:", dest)
-
try {
if (!isMocksEnabled()) {
- if (isAdmin && isSphinx()) {
- // Admin path: pay directly from Sphinx wallet, then record
+ const adminDirect = isAdmin && isSphinx() && !!pubkey
+ if (adminDirect) {
+ // Admin path: pay directly from Sphinx wallet, then record. Requires a
+ // real pubkey — when pubkey is unavailable (post-4d), falls through to
+ // the /boost path below where boltwall keysends from its own node.
+ const dest = routeHint ? { pubkey, route_hint: routeHint } : parsePubkeyWithHint(pubkey!)
await adminKeysend(dest.pubkey, DEFAULT_BOOST_AMOUNT, dest.route_hint)
await api.post("/boost/record", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest })
} else {
- // Regular user path: L402-gated boost
+ // Regular path: L402-gated boost. Server resolves contributor identity
+ // (keysend vs anon-credit) from the owner_reference_id.
+ const body = { refid: refId, amount: DEFAULT_BOOST_AMOUNT, owner_reference_id: ownerReference }
try {
- await api.post("/boost", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest })
+ await api.post("/boost", body)
} catch (err) {
- // 402 = insufficient LSAT balance — buy/top-up and retry
if (err instanceof Response && err.status === 402) {
await payL402(setBudget)
- await api.post("/boost", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest })
+ await api.post("/boost", body)
} else {
throw err
}
@@ -71,7 +81,7 @@ export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, classNam
} finally {
setBoosting(false)
}
- }, [refId, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance])
+ }, [refId, ownerReference, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance])
return (
diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx
index f4dd233..994dc54 100644
--- a/src/components/layout/node-preview-panel.tsx
+++ b/src/components/layout/node-preview-panel.tsx
@@ -456,8 +456,6 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const [fullNode, setFullNode] = useState
(null)
const [price, setPrice] = useState(null)
const refreshBalance = useUserStore((s) => s.refreshBalance)
- const userPubKey = useUserStore((s) => s.pubKey)
- const userRouteHint = useUserStore((s) => s.routeHint)
const isAdmin = useUserStore((s) => s.isAdmin)
const openModal = useModalStore((s) => s.open)
@@ -467,13 +465,16 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const { icon: PlaceholderIcon, accent: schemaAccent } = getSchemaIconInfo(schema?.icon)
const props = node.properties
const nodeIsBlocked = isBlockedStatus(props?.status)
+ const ownerReference = typeof props?.owner_reference_id === "string" ? props.owner_reference_id : undefined
+ // Legacy pubkey/route_hint for the admin direct-keysend path; phase-4d removes them.
const pubkey = typeof props?.pubkey === "string" ? props.pubkey : undefined
const routeHint = typeof props?.route_hint === "string" ? props.route_hint : undefined
const boostAmt = typeof props?.boost === "number" ? props.boost : 0
- const userFullPubkey = userPubKey && userRouteHint ? `${userPubKey}_${userRouteHint}` : userPubKey
- const isContributor = !!pubkey && pubkey === userFullPubkey
- const hideBoost = isAdmin || isContributor
+ // Self-boost detection moved server-side: caller's L402 isn't known to the
+ // frontend, so /boost rejects with SELF_BOOST when caller equals contributor.
+ // Admin still hides locally to match the existing UX.
+ const hideBoost = isAdmin
let title = pickString(props, schema?.title_key) ?? pickString(props, schema?.index)
if (!title) {
@@ -621,10 +622,11 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
>
{displayNodeType(nodeType)}
- {pubkey && !hideBoost && (
+ {ownerReference && !hideBoost && (
- {!hideBoost && pubkey && (
+ {!hideBoost && ownerReference && (
e.stopPropagation()} className="shrink-0">
)}
- {!hideBoost && !pubkey && boostAmt > 0 && (
+ {!hideBoost && !ownerReference && boostAmt > 0 && (
{boostAmt}
diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx
index 64ff111..31fca5a 100644
--- a/src/lib/__tests__/node-preview-panel.test.tsx
+++ b/src/lib/__tests__/node-preview-panel.test.tsx
@@ -256,38 +256,25 @@ describe("NodePreviewPanel – boost visibility", () => {
beforeEach(() => {
vi.clearAllMocks()
userStoreOverrides = {}
- // Default: api probe returns 200 so BoostButton area is reached
- mockApiGet.mockResolvedValue({ nodes: [{ ref_id: "abc", node_type: "Topic", properties: { name: "Test Node", pubkey: "03abc" } }], edges: [] })
})
- const nodeWithPubkey = (pubkey: string): GraphNode => ({
+ const nodeWithOwner = (ownerReferenceId: string): GraphNode => ({
ref_id: "abc",
node_type: "Topic",
- properties: { name: "Test Node", pubkey },
+ properties: { name: "Test Node", owner_reference_id: ownerReferenceId },
})
- it("hides BoostButton when bare pubkey matches user pubKey (contributor)", async () => {
- userStoreOverrides = { pubKey: "03abc", routeHint: "", isAdmin: false }
- mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc")))
+ // Note: phase-4b moves self-boost detection server-side. The frontend no
+ // longer hides BoostButton when the viewer matches the contributor — /boost
+ // returns SELF_BOOST instead. Tests below cover the remaining client gates.
- const { container } = render(
-
- )
-
- await waitFor(() => {
- expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull()
- })
- // BoostButton mock renders null, but its parent div should not be in DOM
- expect(container.querySelector(".ml-auto")).toBeNull()
- })
-
- it("hides BoostButton when compound pubkey matches user pubKey_routeHint (contributor)", async () => {
- userStoreOverrides = { pubKey: "03abc", routeHint: "02xyz_123456", isAdmin: false }
- const compoundPubkey = "03abc_02xyz_123456"
- mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey(compoundPubkey)))
+ it("hides BoostButton when isAdmin is true", async () => {
+ userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: true }
+ const node = nodeWithOwner("lsat:11111111-1111-1111-1111-111111111111")
+ mockApiGet.mockResolvedValue(makeGraphData(node))
const { container } = render(
-
+
)
await waitFor(() => {
@@ -296,32 +283,34 @@ describe("NodePreviewPanel – boost visibility", () => {
expect(container.querySelector(".ml-auto")).toBeNull()
})
- it("hides BoostButton when isAdmin is true regardless of pubkey", async () => {
- userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: true }
- mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc")))
+ it("renders BoostButton wrapper when node has owner_reference_id and viewer is not admin", async () => {
+ userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: false }
+ const node = nodeWithOwner("lsat:11111111-1111-1111-1111-111111111111")
+ mockApiGet.mockResolvedValue(makeGraphData(node))
const { container } = render(
-
+
)
await waitFor(() => {
expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull()
})
- expect(container.querySelector(".ml-auto")).toBeNull()
+ expect(container.querySelector(".ml-auto")).not.toBeNull()
})
- it("renders BoostButton wrapper when non-owner non-admin views a pubkey node", async () => {
+ it("does not render BoostButton wrapper when node has no owner_reference_id", async () => {
userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: false }
- mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc")))
+ const node: GraphNode = { ref_id: "abc", node_type: "Topic", properties: { name: "Test Node" } }
+ mockApiGet.mockResolvedValue(makeGraphData(node))
const { container } = render(
-
+
)
await waitFor(() => {
expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull()
})
- expect(container.querySelector(".ml-auto")).not.toBeNull()
+ expect(container.querySelector(".ml-auto")).toBeNull()
})
})