diff --git a/README.md b/README.md index c3e2332..f3f4313 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ VITE_BLOCKS_LIST_ENTRIES=100 VITE_CHAIN_INFO_ENTRIES=15 VITE_MARKET_DATA_REFETCH_INTERVAL=120000 VITE_NODE_URL="" # Optional, set to (e.g. 'https://nodes.dusk.network' to) override default +VITE_DUSKEVM_ADAPTER_URL="/duskevm-rpc" # Optional adapter RPC URL for DuskEVM transaction links +DUSKEVM_ADAPTER_PROXY_TARGET="http://localhost:8080" # Dev proxy target VITE_PROVISIONERS_REFETCH_INTERVAL=30000 VITE_REFETCH_INTERVAL=10000 VITE_RUSK_PATH="" # Optional, set to '/rusk' for dev mode @@ -44,6 +46,8 @@ The application defaults to setting the node URL to `/`. In dev mode, requests m The application will determine which network it is connected to by the subdomain it is hosted under, to override this and connect to any node set `VITE_NODE_URL`. Note that only `https://` protocol URLs are valid. +The `/tx/` compatibility route resolves the native Dusk transaction ID through `duskevm_getDuskTransactionIdByHash`. The `/block/` compatibility route resolves Dusk block heights through Rusk. These routes allow a DuskEVM Blockscout instance to use the Dusk explorer as its parent-chain explorer. The transaction resolver uses `/duskevm-rpc` by default. Deployments should reverse proxy that path to the adapter and expose only the methods required by the explorer. In development, `DUSKEVM_ADAPTER_PROXY_TARGET` selects the proxy target. + ## NPM scripts - `npm run build` generates the production build diff --git a/src/lib/duskevm/__tests__/resolveBlockId.spec.js b/src/lib/duskevm/__tests__/resolveBlockId.spec.js new file mode 100644 index 0000000..88fa6d3 --- /dev/null +++ b/src/lib/duskevm/__tests__/resolveBlockId.spec.js @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveBlockId } from "../resolveBlockId"; + +describe("resolveBlockId", () => { + const blockHash = "ab".repeat(32); + + it("returns native block hashes without a lookup", async () => { + const getBlockHashByHeight = vi.fn(); + + await expect( + resolveBlockId(blockHash.toUpperCase(), getBlockHashByHeight) + ).resolves.toBe(blockHash); + expect(getBlockHashByHeight).not.toHaveBeenCalled(); + }); + + it("resolves block heights through the Dusk node", async () => { + const getBlockHashByHeight = vi.fn().mockResolvedValue(blockHash); + + await expect(resolveBlockId("6738", getBlockHashByHeight)).resolves.toBe( + blockHash + ); + expect(getBlockHashByHeight).toHaveBeenCalledWith(6738); + }); + + it("rejects malformed identifiers before lookup", async () => { + const getBlockHashByHeight = vi.fn(); + + await expect( + resolveBlockId("0x1234", getBlockHashByHeight) + ).rejects.toThrow("Invalid block identifier"); + expect(getBlockHashByHeight).not.toHaveBeenCalled(); + }); + + it("rejects unsafe block heights", async () => { + const getBlockHashByHeight = vi.fn(); + + await expect( + resolveBlockId("9007199254740992", getBlockHashByHeight) + ).rejects.toThrow("Invalid block identifier"); + expect(getBlockHashByHeight).not.toHaveBeenCalled(); + }); + + it("rejects missing blocks", async () => { + const getBlockHashByHeight = vi.fn().mockResolvedValue(""); + + await expect(resolveBlockId("6738", getBlockHashByHeight)).rejects.toThrow( + "Dusk block not found" + ); + }); +}); diff --git a/src/lib/duskevm/__tests__/resolveTransactionId.spec.js b/src/lib/duskevm/__tests__/resolveTransactionId.spec.js new file mode 100644 index 0000000..5c0ae52 --- /dev/null +++ b/src/lib/duskevm/__tests__/resolveTransactionId.spec.js @@ -0,0 +1,128 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveTransactionId } from "../resolveTransactionId"; + +describe("resolveTransactionId", () => { + const adapterUrl = "/duskevm-rpc"; + const canonicalHash = `0x${"AB".repeat(32)}`; + const duskTransactionId = "cd".repeat(32); + const fetchSpy = vi.spyOn(global, "fetch"); + + afterEach(() => { + fetchSpy.mockReset(); + }); + + afterAll(() => { + fetchSpy.mockRestore(); + }); + + it("returns native transaction IDs without calling the adapter", async () => { + await expect( + resolveTransactionId(duskTransactionId.toUpperCase()) + ).resolves.toBe(duskTransactionId); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("resolves canonical hashes through the adapter", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "dusk-explorer", + jsonrpc: "2.0", + result: duskTransactionId.toUpperCase(), + }), + { status: 200 } + ) + ); + + await expect(resolveTransactionId(canonicalHash, adapterUrl)).resolves.toBe( + duskTransactionId + ); + expect(fetchSpy).toHaveBeenCalledWith(adapterUrl, { + body: JSON.stringify({ + id: "dusk-explorer", + jsonrpc: "2.0", + method: "duskevm_getDuskTransactionIdByHash", + params: [canonicalHash.toLowerCase()], + }), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + method: "POST", + }); + }); + + it("rejects malformed identifiers before calling the adapter", async () => { + await expect( + resolveTransactionId("not-a-hash", adapterUrl) + ).rejects.toThrow("Invalid transaction identifier"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("requires an adapter URL for canonical hashes", async () => { + await expect(resolveTransactionId(canonicalHash, "")).rejects.toThrow( + "DuskEVM adapter URL is not configured" + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects missing mappings", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "dusk-explorer", + jsonrpc: "2.0", + result: null, + }), + { status: 200 } + ) + ); + + await expect( + resolveTransactionId(canonicalHash, adapterUrl) + ).rejects.toThrow("Dusk transaction not found"); + }); + + it("rejects adapter errors", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { code: -32000, message: "mapping unavailable" }, + id: "dusk-explorer", + jsonrpc: "2.0", + }), + { status: 200 } + ) + ); + + await expect( + resolveTransactionId(canonicalHash, adapterUrl) + ).rejects.toThrow("mapping unavailable"); + }); + + it("rejects malformed adapter results", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "dusk-explorer", + jsonrpc: "2.0", + result: "0x1234", + }), + { status: 200 } + ) + ); + + await expect( + resolveTransactionId(canonicalHash, adapterUrl) + ).rejects.toThrow("DuskEVM adapter returned an invalid transaction ID"); + }); + + it("rejects failed HTTP requests", async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 503 })); + + await expect( + resolveTransactionId(canonicalHash, adapterUrl) + ).rejects.toThrow("DuskEVM adapter request failed (503)"); + }); +}); diff --git a/src/lib/duskevm/resolveBlockId.js b/src/lib/duskevm/resolveBlockId.js new file mode 100644 index 0000000..97e5bf5 --- /dev/null +++ b/src/lib/duskevm/resolveBlockId.js @@ -0,0 +1,39 @@ +import { duskAPI } from "$lib/services"; + +const nativeBlockHashPattern = /^[0-9a-f]{64}$/i; +const blockHeightPattern = /^\d+$/; + +/** + * Resolve a native Dusk block hash or block height to the block hash used by + * the explorer's block details page. + * + * @param {string} value + * @param {(height: number) => Promise} [getBlockHashByHeight] + * @returns {Promise} + */ +export const resolveBlockId = async ( + value, + getBlockHashByHeight = duskAPI.getBlockHashByHeight +) => { + if (nativeBlockHashPattern.test(value)) { + return value.toLowerCase(); + } + + if (!blockHeightPattern.test(value)) { + throw new Error("Invalid block identifier"); + } + + const height = Number(value); + + if (!Number.isSafeInteger(height)) { + throw new Error("Invalid block identifier"); + } + + const blockHash = await getBlockHashByHeight(height); + + if (!nativeBlockHashPattern.test(blockHash)) { + throw new Error("Dusk block not found"); + } + + return blockHash.toLowerCase(); +}; diff --git a/src/lib/duskevm/resolveTransactionId.js b/src/lib/duskevm/resolveTransactionId.js new file mode 100644 index 0000000..24ffca9 --- /dev/null +++ b/src/lib/duskevm/resolveTransactionId.js @@ -0,0 +1,65 @@ +const nativeTransactionIdPattern = /^[0-9a-f]{64}$/i; +const canonicalTransactionHashPattern = /^0x[0-9a-f]{64}$/i; + +/** + * Resolve either a native Dusk transaction ID or an adapter-canonical hash to + * the native transaction ID understood by the Dusk explorer. + * + * @param {string} value + * @param {string} [adapterUrl] + * @returns {Promise} + */ +export const resolveTransactionId = async ( + value, + adapterUrl = import.meta.env.VITE_DUSKEVM_ADAPTER_URL || "/duskevm-rpc" +) => { + if (nativeTransactionIdPattern.test(value)) { + return value.toLowerCase(); + } + + if (!canonicalTransactionHashPattern.test(value)) { + throw new Error("Invalid transaction identifier"); + } + + if (!adapterUrl) { + throw new Error("DuskEVM adapter URL is not configured"); + } + + const canonicalHash = `0x${value.slice(2).toLowerCase()}`; + const response = await fetch(adapterUrl, { + body: JSON.stringify({ + id: "dusk-explorer", + jsonrpc: "2.0", + method: "duskevm_getDuskTransactionIdByHash", + params: [canonicalHash], + }), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + throw new Error(`DuskEVM adapter request failed (${response.status})`); + } + + const payload = await response.json(); + + if (payload.error) { + throw new Error(payload.error.message || "DuskEVM adapter request failed"); + } + + if (payload.result === null) { + throw new Error("Dusk transaction not found"); + } + + if ( + typeof payload.result !== "string" || + !nativeTransactionIdPattern.test(payload.result) + ) { + throw new Error("DuskEVM adapter returned an invalid transaction ID"); + } + + return payload.result.toLowerCase(); +}; diff --git a/src/routes/block/[id]/+page.js b/src/routes/block/[id]/+page.js new file mode 100644 index 0000000..d43d0cd --- /dev/null +++ b/src/routes/block/[id]/+page.js @@ -0,0 +1 @@ +export const prerender = false; diff --git a/src/routes/block/[id]/+page.svelte b/src/routes/block/[id]/+page.svelte new file mode 100644 index 0000000..3359b14 --- /dev/null +++ b/src/routes/block/[id]/+page.svelte @@ -0,0 +1,46 @@ + + +
+ {#if error} + +

The linked Dusk block could not be found.

+
+ {:else} + +

Resolving the block on Dusk.

+
+ {/if} +
diff --git a/src/routes/tx/[hash]/+page.js b/src/routes/tx/[hash]/+page.js new file mode 100644 index 0000000..d43d0cd --- /dev/null +++ b/src/routes/tx/[hash]/+page.js @@ -0,0 +1 @@ +export const prerender = false; diff --git a/src/routes/tx/[hash]/+page.svelte b/src/routes/tx/[hash]/+page.svelte new file mode 100644 index 0000000..5a85b55 --- /dev/null +++ b/src/routes/tx/[hash]/+page.svelte @@ -0,0 +1,46 @@ + + +
+ {#if error} + +

The linked Dusk transaction could not be found.

+
+ {:else} + +

Resolving the transaction on Dusk.

+
+ {/if} +
diff --git a/vite.config.js b/vite.config.js index 84e00eb..b86a82a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from "vite"; import { execSync } from "child_process"; export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd()); + const env = loadEnv(mode, process.cwd(), ""); const buildDate = new Date().toISOString().substring(0, 10); const buildHash = execSync( "git log -1 --grep='explorer:' --format=format:'%h'" @@ -44,6 +44,10 @@ export default defineConfig(({ mode }) => { }, server: { proxy: { + "/duskevm-rpc": { + rewrite: () => "/", + target: env.DUSKEVM_ADAPTER_PROXY_TARGET || "http://localhost:8080", + }, "/rusk": { rewrite: (path) => path.replace(/^\/rusk/, ""), target: "http://localhost:8080/",