Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { makeEntryPath } from "@/utils";
import { getPostQueryOptions, QueryKeys } from "@ecency/sdk";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { DeletedPostScreen } from "./deleted-post-screen";
import { EntryPendingIndexView } from "./entry-pending-index-view";

Expand All @@ -18,6 +18,9 @@ interface Props {
const POLL_INTERVAL = 3_000;
const POLL_TIMEOUT = 30_000;

// Verification phase: 3 actual query completions before showing deleted
const VERIFY_MAX_POLLS = 3;

export function EntryNotFoundFallback({ username, permlink }: Props) {
const queryClient = useQueryClient();
const router = useRouter();
Expand All @@ -33,6 +36,10 @@ export function EntryNotFoundFallback({ username, permlink }: Props) {
const [isTimedOut, setIsTimedOut] = useState(false);
const [hasTransitioned, setHasTransitioned] = useState(false);

// For non-optimistic entries: verification polling before concluding deleted
const [verifyPollCount, setVerifyPollCount] = useState(0);
const isVerifying = !isOptimistic && !hasTransitioned && verifyPollCount < VERIFY_MAX_POLLS;

const handleSuccess = useCallback(
(realEntry: Entry) => {
if (hasTransitioned) return;
Expand All @@ -49,10 +56,10 @@ export function EntryNotFoundFallback({ username, permlink }: Props) {

// Poll blockchain via SDK (with node failover + DMCA filtering)
// Uses separate query key to avoid overwriting optimistic cache
const { data: polledEntry } = useQuery({
const { data: polledEntry, dataUpdatedAt, isError, errorUpdatedAt } = useQuery({
...getPostQueryOptions(username, permlink),
queryKey: ["entry-chain-poll", username, permlink],
enabled: !!isOptimistic && !hasTransitioned,
enabled: (!!isOptimistic && !hasTransitioned) || isVerifying,
refetchInterval: POLL_INTERVAL,
refetchIntervalInBackground: false,
retry: false
Expand All @@ -65,14 +72,59 @@ export function EntryNotFoundFallback({ username, permlink }: Props) {
}
}, [polledEntry, handleSuccess]);

// Timeout after 30s
// Track verification poll count based on actual query completions (success or error)
const prevUpdatedAt = useRef({ data: dataUpdatedAt, error: errorUpdatedAt });
useEffect(() => {
if (!isOptimistic && isVerifying) {
const dataChanged = dataUpdatedAt > 0 && dataUpdatedAt !== prevUpdatedAt.current.data;
const errorChanged = errorUpdatedAt > 0 && errorUpdatedAt !== prevUpdatedAt.current.error;
if (dataChanged || errorChanged) {
prevUpdatedAt.current = { data: dataUpdatedAt, error: errorUpdatedAt };
setVerifyPollCount((c) => c + 1);
}
}
}, [isOptimistic, isVerifying, dataUpdatedAt, errorUpdatedAt]);

// Timeout after 30s (optimistic path)
useEffect(() => {
if (!isOptimistic) return;
const timer = setTimeout(() => setIsTimedOut(true), POLL_TIMEOUT);
return () => clearTimeout(timer);
}, [isOptimistic]);

// No optimistic data — genuinely deleted post
// Non-optimistic: still verifying — show minimal loading
if (isVerifying) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-gray-500 dark:text-gray-400 animate-pulse">
Loading...
</div>
</div>
);
}

// Non-optimistic: query errored (network/RPC failure) — show retry prompt
// instead of incorrectly showing deleted post
if (!isOptimistic && isError) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<div className="text-gray-500 dark:text-gray-400">
Unable to load this post. Please check your connection and try again.
</div>
<button
className="px-4 py-2 rounded bg-blue-dark-sky text-white hover:opacity-90"
onClick={() => {
setVerifyPollCount(0);
router.refresh();
}}
>
Retry
</button>
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// No optimistic data and verification exhausted — genuinely deleted post
if (!isOptimistic) {
return <DeletedPostScreen username={username} permlink={permlink} />;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
"import": "Import",
"clear": "Clear",
"play": "Play",
"pause": "Pause"
"pause": "Pause",
"updated": "Updated"
},
"validation": {
"required": "Required field",
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2.0.21

### Patch Changes

- Fallback of RPC selection (#692)

## 2.0.20

### Patch Changes
Expand Down
15 changes: 14 additions & 1 deletion packages/sdk/dist/browser/index.d.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/sdk/dist/browser/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/browser/index.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/sdk/dist/node/index.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/node/index.cjs.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/sdk/dist/node/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/node/index.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ecency/sdk",
"private": false,
"version": "2.0.20",
"version": "2.0.21",
"description": "Ecency SDK",
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/modules/bridge/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./requests";
export * from "./verify-on-alternate-node";
190 changes: 190 additions & 0 deletions packages/sdk/src/modules/bridge/verify-on-alternate-node.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CONFIG } from "@/modules/core";

// Must use vi.hoisted for variables referenced in vi.mock factories
const mockCall = vi.hoisted(() => vi.fn());
const mockClientConstructor = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({ call: mockCall }))
);

vi.mock("@hiveio/dhive", () => ({
Client: mockClientConstructor,
}));

vi.mock("@/modules/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/core")>();
return {
...actual,
CONFIG: {
hiveClient: {
address: [
"https://api.hive.blog",
"https://api.deathwing.me",
"https://api.openhive.network",
],
currentAddress: "https://api.hive.blog",
},
},
};
});

// Import after mocks are set up
import { verifyPostOnAlternateNode, MAX_ALTERNATE_NODES } from "./verify-on-alternate-node";

describe("verifyPostOnAlternateNode", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset node config
(CONFIG.hiveClient as any).address = [
"https://api.hive.blog",
"https://api.deathwing.me",
"https://api.openhive.network",
];
(CONFIG.hiveClient as any).currentAddress = "https://api.hive.blog";
});

it("should return null when node list has fewer than 2 nodes", async () => {
(CONFIG.hiveClient as any).address = ["https://api.hive.blog"];

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");
expect(result).toBeNull();
expect(mockClientConstructor).not.toHaveBeenCalled();
expect(mockCall).not.toHaveBeenCalled();
});

it("should skip the primary node snapshot and try alternates", async () => {
const mockEntry = { author: "author", permlink: "permlink", post_id: 123 };
mockCall.mockResolvedValueOnce(mockEntry);

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toEqual(mockEntry);
expect(mockClientConstructor).toHaveBeenCalledTimes(1);
expect(mockClientConstructor).toHaveBeenCalledWith(
"https://api.deathwing.me",
expect.objectContaining({ timeout: 10000 })
);
expect(mockCall).toHaveBeenCalledTimes(1);
expect(mockCall).toHaveBeenCalledWith("bridge", "get_post", {
author: "author",
permlink: "permlink",
observer: "",
});
});

it("should return null when all alternate nodes return null", async () => {
mockCall.mockResolvedValue(null);

const result = await verifyPostOnAlternateNode("author", "permlink", "obs", "https://api.hive.blog");

expect(result).toBeNull();
expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
expect(mockClientConstructor).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
// Verify each alternate was tried with correct URL
expect(mockClientConstructor).toHaveBeenNthCalledWith(
1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 })
);
expect(mockClientConstructor).toHaveBeenNthCalledWith(
2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 })
);
});

it("should try next node when first alternate throws", async () => {
const mockEntry = { author: "author", permlink: "permlink", post_id: 456 };
mockCall.mockRejectedValueOnce(new Error("timeout")).mockResolvedValueOnce(mockEntry);

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toEqual(mockEntry);
expect(mockCall).toHaveBeenCalledTimes(2);
expect(mockClientConstructor).toHaveBeenNthCalledWith(
1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 })
);
expect(mockClientConstructor).toHaveBeenNthCalledWith(
2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 })
);
});

it("should return null when all alternates throw", async () => {
mockCall.mockRejectedValue(new Error("network error"));

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toBeNull();
expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
});

it("should try at most MAX_ALTERNATE_NODES alternate nodes", async () => {
(CONFIG.hiveClient as any).address = [
"https://node1.com",
"https://node2.com",
"https://node3.com",
"https://node4.com",
"https://node5.com",
];
mockCall.mockResolvedValue(null);

await verifyPostOnAlternateNode("author", "permlink", "", "https://node1.com");

expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
expect(mockClientConstructor).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
});

it("should fall back to currentAddress when no primaryNode provided", async () => {
mockCall.mockResolvedValue(null);

const result = await verifyPostOnAlternateNode("author", "permlink", "");

expect(result).toBeNull();
// Falls back to CONFIG.hiveClient.currentAddress (hive.blog), excludes it by identity
expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
expect(mockClientConstructor).toHaveBeenNthCalledWith(
1, "https://api.deathwing.me", expect.objectContaining({ timeout: 10000 })
);
expect(mockClientConstructor).toHaveBeenNthCalledWith(
2, "https://api.openhive.network", expect.objectContaining({ timeout: 10000 })
);
});

it("should use primaryNode snapshot to exclude the correct node even if currentAddress changed", async () => {
// Simulate: primary was hive.blog, but failover moved currentAddress to deathwing
(CONFIG.hiveClient as any).currentAddress = "https://api.deathwing.me";
const mockEntry = { author: "author", permlink: "permlink", post_id: 789 };
mockCall.mockResolvedValueOnce(mockEntry);

// Pass the snapshot of hive.blog as primaryNode
const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toEqual(mockEntry);
// Should exclude hive.blog (the snapshot), not deathwing (the current)
expect(mockClientConstructor).toHaveBeenCalledTimes(1);
expect(mockClientConstructor).toHaveBeenCalledWith(
"https://api.deathwing.me",
expect.objectContaining({ timeout: 10000 })
);
});

it("should reject response with mismatched author/permlink", async () => {
// Node returns a valid-looking entry but for a different post
const wrongEntry = { author: "other-author", permlink: "other-permlink", post_id: 999 };
mockCall.mockResolvedValue(wrongEntry);

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toBeNull();
// Tried both alternates, both returned mismatched data
expect(mockCall).toHaveBeenCalledTimes(MAX_ALTERNATE_NODES);
});

it("should accept response only when author and permlink match", async () => {
// First node returns mismatched, second returns correct
const wrongEntry = { author: "wrong", permlink: "permlink", post_id: 100 };
const correctEntry = { author: "author", permlink: "permlink", post_id: 200 };
mockCall.mockResolvedValueOnce(wrongEntry).mockResolvedValueOnce(correctEntry);

const result = await verifyPostOnAlternateNode("author", "permlink", "", "https://api.hive.blog");

expect(result).toEqual(correctEntry);
expect(mockCall).toHaveBeenCalledTimes(2);
});
});
62 changes: 62 additions & 0 deletions packages/sdk/src/modules/bridge/verify-on-alternate-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Client } from "@hiveio/dhive";
import { CONFIG } from "@/modules/core";
import { Entry } from "@/modules/posts/types";

/** Maximum number of alternate nodes to try during verification */
export const MAX_ALTERNATE_NODES = 2;

/**
* When the primary node returns null for a get_post call,
* verify against up to 2 alternate nodes before concluding
* the post is truly deleted. This guards against sync lag
* where a single node temporarily returns null for valid content.
*
* @param primaryNode - Snapshot of CONFIG.hiveClient.currentAddress captured
* before the primary request, so failover can't change which node we exclude.
*/
export async function verifyPostOnAlternateNode(
author: string,
permlink: string,
observer: string,
primaryNode?: string
): Promise<Entry | null> {
const allNodes = CONFIG.hiveClient.address;

// If we can't determine the node list, we can't verify
if (!Array.isArray(allNodes) || allNodes.length < 2) {
return null;
}

// Filter out the node that just returned null
const nodeToExclude = primaryNode ?? CONFIG.hiveClient.currentAddress;
const alternateNodes = nodeToExclude
? allNodes.filter((node) => node !== nodeToExclude)
: [...allNodes];

const nodesToTry = alternateNodes.slice(0, MAX_ALTERNATE_NODES);

for (const node of nodesToTry) {
try {
const client = new Client(node, { timeout: 10000 });
const response = await client.call("bridge", "get_post", {
author,
permlink,
observer,
});

if (
response &&
typeof response === "object" &&
(response as Entry).author === author &&
(response as Entry).permlink === permlink
) {
return response as Entry;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
// Node failed — try next one
continue;
}
}

return null;
}
Loading