diff --git a/example-apps/dashproof-lab/src/App.tsx b/example-apps/dashproof-lab/src/App.tsx index 752dfb7..b3b9428 100644 --- a/example-apps/dashproof-lab/src/App.tsx +++ b/example-apps/dashproof-lab/src/App.tsx @@ -112,6 +112,7 @@ function App() { contractId={session.contractId} onLoginPrompt={() => setLoginOpen(true)} onAnchored={() => setRefreshKey((value) => value + 1)} + onViewChainHistory={openHistoryForChain} /> )} {tab === "verify" && ( diff --git a/example-apps/dashproof-lab/src/components/AnchorForm.tsx b/example-apps/dashproof-lab/src/components/AnchorForm.tsx index 471f32f..1f756bb 100644 --- a/example-apps/dashproof-lab/src/components/AnchorForm.tsx +++ b/example-apps/dashproof-lab/src/components/AnchorForm.tsx @@ -11,8 +11,14 @@ import { import { createAnchor } from "../dash/createAnchor"; import { errorMessage } from "../dash/logger"; +import { findAnchorByHash, type AnchorRecord } from "../dash/queries"; import { suggestChainId } from "../lib/chainId"; -import { formatBytes, formatHashBlocks, formatTimestamp } from "../lib/format"; +import { + formatBytes, + formatHashBlocks, + formatTimestamp, + truncateId, +} from "../lib/format"; import { bytesToHex, hashFile } from "../lib/hash"; import { useSession } from "../session/useSession"; import { OperationResultNotice } from "./OperationResultNotice"; @@ -21,12 +27,14 @@ interface AnchorFormProps { contractId: string | null; onAnchored: () => void; onLoginPrompt: () => void; + onViewChainHistory?: (chainId: string) => void; } export function AnchorForm({ contractId, onAnchored, onLoginPrompt, + onViewChainHistory, }: AnchorFormProps) { const session = useSession(); const [selectedFile, setSelectedFile] = useState(null); @@ -43,6 +51,11 @@ export function AnchorForm({ const [hashCopied, setHashCopied] = useState(false); const [submitting, setSubmitting] = useState(false); const [hashing, setHashing] = useState(false); + const [checkingDuplicate, setCheckingDuplicate] = useState(false); + const [existingAnchor, setExistingAnchor] = useState( + null, + ); + const [anchoredCurrentFile, setAnchoredCurrentFile] = useState(false); const [dragActive, setDragActive] = useState(false); const inputId = "anchor-file-input"; const inputRef = useRef(null); @@ -68,6 +81,9 @@ export function AnchorForm({ setEntryHash(null); setHashHex(""); setHashCopied(false); + setCheckingDuplicate(false); + setExistingAnchor(null); + setAnchoredCurrentFile(false); if (!file) { if (chainIdAutoManagedRef.current) setChainId(""); return; @@ -90,11 +106,39 @@ export function AnchorForm({ } setStatusTone("info"); setStatusText("SHA-256 computed locally in the browser."); + setHashing(false); + + if (!session.sdk || !contractId) return; + + setCheckingDuplicate(true); + try { + const match = await findAnchorByHash({ + sdk: session.sdk, + contractId, + entryHash: digest, + log: session.log, + }); + if (fileSelectionRef.current !== requestId) return; + setExistingAnchor(match); + } catch (err) { + // The unique hash index is the real duplicate guard; a failed + // pre-flight lookup must not block the local hash preview. + if (fileSelectionRef.current === requestId) { + session.log?.(errorMessage(err), "error"); + } + } finally { + if (fileSelectionRef.current === requestId) { + setCheckingDuplicate(false); + } + } } catch (err) { + if (fileSelectionRef.current !== requestId) return; setStatusTone("error"); setStatusText(errorMessage(err)); } finally { - setHashing(false); + if (fileSelectionRef.current === requestId) { + setHashing(false); + } } } @@ -140,32 +184,50 @@ export function AnchorForm({ setHashCopied(true); } - async function handleSubmit(event: FormEvent) { - event.preventDefault(); + function getSubmitPayload() { if ( + session.status !== "authenticated" || !session.sdk || !session.keyManager || !selectedFile || !entryHash || - !contractId + !contractId || + chainId.trim().length === 0 || + checkingDuplicate || + existingAnchor || + anchoredCurrentFile ) { - return; + return null; } + return { + sdk: session.sdk, + keyManager: session.keyManager, + selectedFile, + entryHash, + contractId, + }; + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const submitPayload = getSubmitPayload(); + if (!submitPayload) return; + setSubmitting(true); setStatusText(null); try { await createAnchor({ - sdk: session.sdk, - keyManager: session.keyManager, - contractId, + sdk: submitPayload.sdk, + keyManager: submitPayload.keyManager, + contractId: submitPayload.contractId, log: session.log, anchor: { - entryHash, + entryHash: submitPayload.entryHash, chainId, - filename: selectedFile.name, - mimeType: selectedFile.type, - size: selectedFile.size, + filename: submitPayload.selectedFile.name, + mimeType: submitPayload.selectedFile.type, + size: submitPayload.selectedFile.size, note, }, }); @@ -173,6 +235,7 @@ export function AnchorForm({ setStatusText( "Proof created and anchored on Dash Platform. Duplicate hashes are rejected by design.", ); + setAnchoredCurrentFile(true); onAnchored(); } catch (err) { setStatusTone("error"); @@ -182,14 +245,7 @@ export function AnchorForm({ } } - const canSubmit = - session.status === "authenticated" && - !!session.sdk && - !!session.keyManager && - !!selectedFile && - !!entryHash && - !!contractId && - chainId.trim().length > 0; + const canSubmit = getSubmitPayload() !== null; return (
@@ -314,6 +370,11 @@ export function AnchorForm({ Computing hash… + ) : checkingDuplicate ? ( +
+ + Checking for existing proof… +
) : ( <>
@@ -338,13 +399,32 @@ export function AnchorForm({ /> + {existingAnchor && ( + + This file already has a proof on Dash Platform (chain{" "} + + , anchored {formatTimestamp(existingAnchor.createdAt)} by{" "} + + {truncateId(existingAnchor.ownerId, 12)} + + ). The hash index rejects duplicates, so a new proof can’t be + created for it. + + )} + {statusText && ( {statusText} )} - {session.status !== "authenticated" ? ( + {existingAnchor ? null : session.status !== "authenticated" ? (
Login required for submission diff --git a/example-apps/dashproof-lab/src/dash/createAnchor.ts b/example-apps/dashproof-lab/src/dash/createAnchor.ts index 7a5b1f6..f4125f8 100644 --- a/example-apps/dashproof-lab/src/dash/createAnchor.ts +++ b/example-apps/dashproof-lab/src/dash/createAnchor.ts @@ -6,7 +6,7 @@ import { Document } from "@dashevo/evo-sdk"; import { bytesToBase64, bytesToDocumentArray } from "../lib/hash"; -import type { Logger } from "./logger"; +import { errorMessage, type Logger } from "./logger"; import type { DashKeyManager, DashSdk } from "./types"; export interface CreateAnchorInput { @@ -27,6 +27,18 @@ export interface CreateAnchorParams { log?: Logger; } +export const DUPLICATE_ANCHOR_MESSAGE = + "This file is already anchored. The proof contract rejects duplicate SHA-256 hashes."; + +function isDuplicateAnchorError(err: unknown): boolean { + const message = errorMessage(err).toLowerCase(); + return ( + message.includes("duplicate") || + message.includes("unique") || + message.includes("already exists") + ); +} + export async function createAnchor({ sdk, keyManager, @@ -73,6 +85,14 @@ export async function createAnchor({ dataContractId: contractId, ownerId: identity.id, }); - await sdk.documents.create({ document, identityKey, signer }); + try { + await sdk.documents.create({ document, identityKey, signer }); + } catch (err) { + if (isDuplicateAnchorError(err)) { + throw new Error(DUPLICATE_ANCHOR_MESSAGE); + } + log?.(`Anchor submission failed: ${errorMessage(err)}`, "error", { err }); + throw err; + } log?.("Proof anchor submitted.", "success"); } diff --git a/example-apps/dashproof-lab/src/dash/logger.ts b/example-apps/dashproof-lab/src/dash/logger.ts index 5544323..2c63b8d 100644 --- a/example-apps/dashproof-lab/src/dash/logger.ts +++ b/example-apps/dashproof-lab/src/dash/logger.ts @@ -3,7 +3,11 @@ */ export type LogLevel = "info" | "success" | "error"; -export type Logger = (message: string, level?: LogLevel) => void; +export type Logger = ( + message: string, + level?: LogLevel, + details?: Record, +) => void; export function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; diff --git a/example-apps/dashproof-lab/src/session/SessionContext.tsx b/example-apps/dashproof-lab/src/session/SessionContext.tsx index 40bfb7b..de05b46 100644 --- a/example-apps/dashproof-lab/src/session/SessionContext.tsx +++ b/example-apps/dashproof-lab/src/session/SessionContext.tsx @@ -51,10 +51,14 @@ export function SessionProvider({ children }: { children: ReactNode }) { loadStoredContractId(), ); - const log = useCallback((message, level = "info") => { + const log = useCallback((message, level = "info", details) => { const method = level === "error" ? "error" : level === "success" ? "info" : "log"; - console[method](`[${level}] ${message}`); + if (details) { + console[method](`[${level}] ${message}`, details); + } else { + console[method](`[${level}] ${message}`); + } if (level === "success") toast.success(message); if (level === "error") toast.error(message); }, []); diff --git a/example-apps/dashproof-lab/test/AnchorForm.test.tsx b/example-apps/dashproof-lab/test/AnchorForm.test.tsx index 30c1496..94e84a7 100644 --- a/example-apps/dashproof-lab/test/AnchorForm.test.tsx +++ b/example-apps/dashproof-lab/test/AnchorForm.test.tsx @@ -13,11 +13,13 @@ import { AnchorForm } from "../src/components/AnchorForm"; import { EXAMPLE_FILE_FIXTURES } from "../src/data/exampleFiles"; import { formatHashBlocks } from "../src/lib/format"; -const { mockUseSession, mockHashFile, mockCreateAnchor } = vi.hoisted(() => ({ - mockUseSession: vi.fn(), - mockHashFile: vi.fn(), - mockCreateAnchor: vi.fn(), -})); +const { mockUseSession, mockHashFile, mockCreateAnchor, mockFindAnchorByHash } = + vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockHashFile: vi.fn(), + mockCreateAnchor: vi.fn(), + mockFindAnchorByHash: vi.fn(), + })); vi.mock("../src/session/useSession", () => ({ useSession: mockUseSession, @@ -35,6 +37,10 @@ vi.mock("../src/dash/createAnchor", () => ({ createAnchor: mockCreateAnchor, })); +vi.mock("../src/dash/queries", () => ({ + findAnchorByHash: mockFindAnchorByHash, +})); + function makeSession(overrides: Record = {}) { return { status: "readonly", @@ -49,6 +55,8 @@ beforeEach(() => { mockHashFile.mockReset(); mockCreateAnchor.mockReset(); mockUseSession.mockReset(); + mockFindAnchorByHash.mockReset(); + mockFindAnchorByHash.mockResolvedValue(null); }); afterEach(() => { @@ -149,6 +157,20 @@ describe("AnchorForm", () => { ); }); expect(onAnchored).toHaveBeenCalled(); + + await waitFor(() => { + expect( + ( + screen.getByRole("button", { + name: /create proof/i, + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + const form = screen.getByLabelText(/chain id/i).closest("form"); + expect(form).toBeTruthy(); + fireEvent.submit(form as HTMLFormElement); + expect(mockCreateAnchor).toHaveBeenCalledTimes(1); }); it("auto-fills from the filename, preserves manual edits, and resumes after clearing", async () => { @@ -243,4 +265,97 @@ describe("AnchorForm", () => { expect(screen.getByText("drop-proof.txt")).toBeTruthy(); expect(mockHashFile).toHaveBeenCalledWith(file); }); + + it("warns and blocks submit when the file is already anchored", async () => { + mockUseSession.mockReturnValue( + makeSession({ + status: "authenticated", + keyManager: { getAuth: vi.fn() }, + }), + ); + mockHashFile.mockResolvedValue(new Uint8Array(32).fill(1)); + mockFindAnchorByHash.mockResolvedValue({ + id: "anchor-1", + ownerId: "ownerabcdef0123456789", + createdAt: 1700000000000, + entryHash: new Uint8Array(32).fill(1), + entryHashHex: "01".repeat(32), + chainId: "existing-chain", + }); + const onViewChainHistory = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText(/select file/i), { + target: { + files: [new File(["proof"], "dupe.txt", { type: "text/plain" })], + }, + }); + fireEvent.change(screen.getByLabelText(/chain id/i), { + target: { value: "chain-1" }, + }); + + await screen.findByText("Already anchored"); + expect(mockFindAnchorByHash).toHaveBeenCalledWith( + expect.objectContaining({ contractId: "contract-1" }), + ); + // The submit CTA is removed entirely while a duplicate is detected. + expect(screen.queryByRole("button", { name: /create proof/i })).toBeNull(); + const form = screen.getByLabelText(/chain id/i).closest("form"); + expect(form).toBeTruthy(); + fireEvent.submit(form as HTMLFormElement); + expect(mockCreateAnchor).not.toHaveBeenCalled(); + + // The chain ID links into that chain's history. + fireEvent.click(screen.getByRole("button", { name: "existing-chain" })); + expect(onViewChainHistory).toHaveBeenCalledWith("existing-chain"); + }); + + it("allows submit and shows no warning when the file is not yet anchored", async () => { + mockUseSession.mockReturnValue( + makeSession({ + status: "authenticated", + keyManager: { getAuth: vi.fn() }, + }), + ); + mockHashFile.mockResolvedValue(new Uint8Array(32).fill(2)); + mockFindAnchorByHash.mockResolvedValue(null); + + render( + , + ); + + fireEvent.change(screen.getByLabelText(/select file/i), { + target: { + files: [new File(["fresh"], "fresh.txt", { type: "text/plain" })], + }, + }); + fireEvent.change(screen.getByLabelText(/chain id/i), { + target: { value: "chain-1" }, + }); + + await screen.findByText("SHA-256 computed locally in the browser."); + + await waitFor(() => { + expect( + ( + screen.getByRole("button", { + name: /create proof/i, + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + expect(screen.queryByText("Already anchored")).toBeNull(); + }); }); diff --git a/example-apps/dashproof-lab/test/dash.test.ts b/example-apps/dashproof-lab/test/dash.test.ts index 80edbc1..30b9ad0 100644 --- a/example-apps/dashproof-lab/test/dash.test.ts +++ b/example-apps/dashproof-lab/test/dash.test.ts @@ -17,7 +17,10 @@ vi.mock("@dashevo/evo-sdk", () => ({ }, })); -import { createAnchor } from "../src/dash/createAnchor"; +import { + createAnchor, + DUPLICATE_ANCHOR_MESSAGE, +} from "../src/dash/createAnchor"; import { clearStoredContractId, loadStoredContractId, @@ -297,6 +300,38 @@ describe("dashproof helpers", () => { ).rejects.toThrow("entryHash must be a 32-byte SHA-256 digest."); }); + it("createAnchor explains duplicate hash rejections", async () => { + const mockDocumentsCreate = vi + .fn() + .mockRejectedValue( + new Error("Invalid transition: duplicate unique index violation"), + ); + + await expect( + createAnchor({ + sdk: { + documents: { + create: mockDocumentsCreate, + }, + }, + keyManager: { + async getAuth() { + return { + identity: { id: "identity-1" }, + identityKey: undefined, + signer: undefined, + }; + }, + }, + contractId: "contract-1", + anchor: { + entryHash: new Uint8Array(32), + chainId: "chain-1", + }, + }), + ).rejects.toThrow(DUPLICATE_ANCHOR_MESSAGE); + }); + it("createAnchor rejects a previousId that is not 32 bytes", async () => { await expect( createAnchor({