Skip to content
Draft
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<canonical-hash>` compatibility route resolves the native Dusk transaction ID through `duskevm_getDuskTransactionIdByHash`. The `/block/<height>` 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
Expand Down
51 changes: 51 additions & 0 deletions src/lib/duskevm/__tests__/resolveBlockId.spec.js
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
128 changes: 128 additions & 0 deletions src/lib/duskevm/__tests__/resolveTransactionId.spec.js
Original file line number Diff line number Diff line change
@@ -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)");
});
});
39 changes: 39 additions & 0 deletions src/lib/duskevm/resolveBlockId.js
Original file line number Diff line number Diff line change
@@ -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<string>} [getBlockHashByHeight]
* @returns {Promise<string>}
*/
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();
};
65 changes: 65 additions & 0 deletions src/lib/duskevm/resolveTransactionId.js
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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();
};
1 change: 1 addition & 0 deletions src/routes/block/[id]/+page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const prerender = false;
46 changes: 46 additions & 0 deletions src/routes/block/[id]/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script>
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { resolve } from "$app/paths";

import { Banner } from "$lib/dusk/components";
import { resolveBlockId } from "$lib/duskevm/resolveBlockId";

let error = false;

onMount(() => {
let active = true;
const id = $page.params.id ?? "";

resolveBlockId(id)
.then(async (blockHash) => {
if (active) {
await goto(resolve(`/blocks/block?id=${blockHash}`), {
replaceState: true,
});
}
})
.catch(() => {
if (active) {
error = true;
}
});

return () => {
active = false;
};
});
</script>

<section class="block">
{#if error}
<Banner title="Block link unavailable" variant="error">
<p>The linked Dusk block could not be found.</p>
</Banner>
{:else}
<Banner title="Opening block" variant="info">
<p>Resolving the block on Dusk.</p>
</Banner>
{/if}
</section>
1 change: 1 addition & 0 deletions src/routes/tx/[hash]/+page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const prerender = false;
Loading
Loading