diff --git a/packages/api/src/address/address.controller.spec.ts b/packages/api/src/address/address.controller.spec.ts
index 1db8bb003d..993698ca5e 100644
--- a/packages/api/src/address/address.controller.spec.ts
+++ b/packages/api/src/address/address.controller.spec.ts
@@ -18,6 +18,7 @@ import { ForbiddenException } from "@nestjs/common";
import { Wallet, zeroPadValue } from "ethers";
import { UserWithRoles } from "../api/pipes/addUserRoles.pipe";
import { ConfigService } from "@nestjs/config";
+import { BASE_TOKEN_L2_ADDRESS } from "../common/constants";
jest.mock("../common/utils", () => ({
...jest.requireActual("../common/utils"),
@@ -278,6 +279,11 @@ describe("AddressController", () => {
await expect(controller.getAddress(blockchainAddress, user)).rejects.toThrow(ForbiddenException);
});
+ it("does not throw if address is the base token address", async () => {
+ serviceMock.findOne.mockResolvedValue(null);
+ await expect(controller.getAddress(BASE_TOKEN_L2_ADDRESS, user)).resolves.toBeDefined();
+ });
+
describe("when address is a contract", () => {
beforeEach(() => {
serviceMock.findOne.mockResolvedValue(mock
({ address: blockchainAddress, bytecode: "0x123" }));
diff --git a/packages/api/src/address/address.controller.ts b/packages/api/src/address/address.controller.ts
index 2e0224df37..09fdbeffa7 100644
--- a/packages/api/src/address/address.controller.ts
+++ b/packages/api/src/address/address.controller.ts
@@ -25,6 +25,7 @@ import { TransferService } from "../transfer/transfer.service";
import { TransferDto } from "../transfer/transfer.dto";
import { swagger } from "../config/featureFlags";
import { constants } from "../config/docs";
+import { BASE_TOKEN_L2_ADDRESS } from "../common/constants";
import { User } from "../user/user.decorator";
import { AddUserRolesPipe, UserWithRoles } from "../api/pipes/addUserRoles.pipe";
@@ -70,6 +71,9 @@ export class AddressController {
const addressType = !!(addressRecord && addressRecord.bytecode.length > 2)
? AddressType.Contract
: AddressType.Account;
+
+ const isPublicBaseTokenAddress = isAddressEqual(address, BASE_TOKEN_L2_ADDRESS);
+
let includeBalances = true;
let includeBytecode = true;
let includeCreatorAddress = true;
@@ -77,7 +81,7 @@ export class AddressController {
if (user && !user.isAdmin) {
// If address is an account and is not own address, forbid access
- if (addressType === AddressType.Account && !isAddressEqual(user.address, address)) {
+ if (addressType === AddressType.Account && !isAddressEqual(user.address, address) && !isPublicBaseTokenAddress) {
throw new ForbiddenException();
}
diff --git a/packages/app/src/components/Token.vue b/packages/app/src/components/Token.vue
index 33af5ceb65..88966c43a7 100644
--- a/packages/app/src/components/Token.vue
+++ b/packages/app/src/components/Token.vue
@@ -54,9 +54,10 @@
-
+
+
-
+
@@ -79,6 +80,7 @@ import ContractEvents from "@/components/event/ContractEvents.vue";
import MarketTokenInfoTable from "@/components/token/MarketTokenInfoTable.vue";
import OverviewTokenInfoTable from "@/components/token/OverviewTokenInfoTable.vue";
import TransactionsTable from "@/components/transactions/Table.vue";
+import TransfersTable from "@/components/transfers/Table.vue";
import useContext from "@/composables/useContext";
import useRuntimeConfig from "@/composables/useRuntimeConfig";
@@ -112,6 +114,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ isBaseToken: {
+ type: Boolean,
+ default: false,
+ },
});
const { getTokenInfo, tokenInfo } = useToken();
@@ -126,12 +132,16 @@ watchEffect(() => {
const tabs = computed(() => [
{ title: t("tabs.transactions"), hash: "#transactions" },
- {
- title: t("tabs.contract"),
- hash: "#contract",
- icon: props.contract?.verificationInfo ? CheckCircleIcon : null,
- },
- { title: t("tabs.events"), hash: showEventsTab.value ? "#events" : null },
+ ...(props.isBaseToken
+ ? [{ title: t("tabs.transfers"), hash: "#transfers" }]
+ : [
+ {
+ title: t("tabs.contract"),
+ hash: "#contract",
+ icon: props.contract?.verificationInfo ? CheckCircleIcon : null,
+ },
+ { title: t("tabs.events"), hash: showEventsTab.value ? "#events" : null },
+ ]),
]);
const breadcrumbItems = computed((): BreadcrumbItem[] | [] => {
diff --git a/packages/app/src/components/transfers/Table.vue b/packages/app/src/components/transfers/Table.vue
index f52883fae5..22cd9e10c9 100644
--- a/packages/app/src/components/transfers/Table.vue
+++ b/packages/app/src/components/transfers/Table.vue
@@ -90,6 +90,7 @@
null,
},
+ forToken: {
+ type: Boolean,
+ default: false,
+ },
});
const { data, load, total, pending, pageSize } = useTransfers(
computed(() => {
return props.address;
- })
+ }),
+ { forToken: props.forToken }
);
function getTransferDirection(item: Transfer): Direction {
@@ -185,7 +191,7 @@ const toDate = new Date();
watch(
[activePage, () => props.address],
([page]) => {
- load(page, toDate);
+ load(page, props.forToken ? undefined : toDate);
},
{ immediate: true }
);
diff --git a/packages/app/src/composables/useSearch.ts b/packages/app/src/composables/useSearch.ts
index 2f04fc87c6..357ea39534 100644
--- a/packages/app/src/composables/useSearch.ts
+++ b/packages/app/src/composables/useSearch.ts
@@ -6,23 +6,30 @@ import { FetchError } from "ohmyfetch";
import useContext from "./useContext";
import { FetchInstance } from "./useFetchInstance";
-import { isAddress, isBlockNumber, isTransactionHash } from "@/utils/validators";
+import { isAddress, isAddressEqual, isBlockNumber, isTransactionHash } from "@/utils/validators";
export default (context = useContext()) => {
const router = useRouter();
const isRequestPending = ref(false);
const isRequestFailed = ref(false);
+ const isPrividiumBaseTokenAddress = (address: string) =>
+ !!context.currentNetwork.value.prividium && isAddressEqual(address, context.currentNetwork.value.baseTokenAddress);
+
const getSearchRoute = (param: string) => {
try {
- const searchRoutes = [
- {
+ if (isAddress(param)) {
+ const isBaseTokenAddress = isPrividiumBaseTokenAddress(param);
+ return {
routeParam: { address: param },
apiRoute: "address",
- isValid: () => isAddress(param),
- routeName: "address",
+ isValid: () => true,
+ routeName: isBaseTokenAddress ? "token" : "address",
prefetch: true,
- },
+ };
+ }
+
+ const searchRoutes = [
{
routeParam: { id: param },
apiRoute: "blocks",
diff --git a/packages/app/src/composables/useTransfers.ts b/packages/app/src/composables/useTransfers.ts
index ed710af25c..4a958c70b7 100644
--- a/packages/app/src/composables/useTransfers.ts
+++ b/packages/app/src/composables/useTransfers.ts
@@ -9,13 +9,22 @@ export type Transfer = Api.Response.Transfer & {
toNetwork: NetworkOrigin;
};
-export default (address: ComputedRef, context = useContext()) => {
+interface UseTransferOptions {
+ forToken?: boolean;
+}
+
+export default (
+ address: ComputedRef,
+ { forToken = false }: UseTransferOptions = {},
+ context = useContext()
+) => {
return useFetchCollection(
- () =>
- new URL(
- `/address/${address.value}/transfers?toDate=${new Date().toISOString()}`,
- context.currentNetwork.value.apiUrl
- ),
+ () => {
+ const path = forToken
+ ? `/tokens/${address.value}/transfers`
+ : `/address/${address.value}/transfers?toDate=${new Date().toISOString()}`;
+ return new URL(path, context.currentNetwork.value.apiUrl);
+ },
(transfer: Api.Response.Transfer): Transfer => ({
...transfer,
token: transfer.token || {
diff --git a/packages/app/src/utils/validators.ts b/packages/app/src/utils/validators.ts
index 6ea5e7bb8f..d7e07ecd2a 100644
--- a/packages/app/src/utils/validators.ts
+++ b/packages/app/src/utils/validators.ts
@@ -1,4 +1,4 @@
-import { AbiCoder, isAddress as ethersIsAddress } from "ethers";
+import { AbiCoder, isAddress as ethersIsAddress, getAddress } from "ethers";
const defaultAbiCoder: AbiCoder = AbiCoder.defaultAbiCoder();
@@ -6,6 +6,18 @@ export function isAddress(address: string): boolean {
return ethersIsAddress(address?.toLowerCase());
}
+export function isAddressEqual(address1?: string | null, address2?: string | null): boolean {
+ if (!address1 || !address2) {
+ return false;
+ }
+
+ try {
+ return getAddress(address1) === getAddress(address2);
+ } catch {
+ return false;
+ }
+}
+
export const isTransactionHash = (s: string) => {
return /^0x([A-Fa-f0-9]{64})$/.test(s);
};
diff --git a/packages/app/src/views/TokenView.vue b/packages/app/src/views/TokenView.vue
index 373470db42..8365c10740 100644
--- a/packages/app/src/views/TokenView.vue
+++ b/packages/app/src/views/TokenView.vue
@@ -3,8 +3,14 @@
-
-
+
+
@@ -16,11 +22,13 @@ import PageError from "@/components/PageError.vue";
import TokenView from "@/components/Token.vue";
import useAddress, { type Account, type Contract } from "@/composables/useAddress";
+import useContext from "@/composables/useContext";
import useNotFound from "@/composables/useNotFound";
-import { isAddress } from "@/utils/validators";
+import { isAddress, isAddressEqual } from "@/utils/validators";
const { useNotFoundView, setNotFoundView } = useNotFound();
+const { currentNetwork } = useContext();
const { item, isRequestPending: pending, isRequestFailed: failed, getByAddress } = useAddress();
@@ -35,6 +43,42 @@ const pageType = computed(() => {
return item.value?.type ? item.value?.type : "account";
});
+const isPrividiumBaseTokenAddress = computed(() => {
+ const baseTokenAddress = currentNetwork.value.baseTokenAddress;
+ return currentNetwork.value.prividium && isAddressEqual(props.address, baseTokenAddress);
+});
+
+const showTokenComponent = computed(() => pageType.value !== "account" || isPrividiumBaseTokenAddress.value);
+
+const tokenContract = computed(() => {
+ if (!item.value) {
+ return null;
+ }
+
+ if (item.value.type === "contract") {
+ return item.value as Contract;
+ }
+
+ if (isPrividiumBaseTokenAddress.value) {
+ return {
+ type: "contract",
+ address: item.value.address,
+ blockNumber: item.value.blockNumber,
+ balances: item.value.balances,
+ bytecode: "",
+ creatorAddress: "",
+ creatorTxHash: "",
+ createdInBlockNumber: 0,
+ totalTransactions: 0,
+ isEvmLike: true,
+ verificationInfo: null,
+ proxyInfo: null,
+ };
+ }
+
+ return null;
+});
+
useNotFoundView(pending, failed, item);
watchEffect(() => {
diff --git a/packages/app/tests/composables/useSearch.spec.ts b/packages/app/tests/composables/useSearch.spec.ts
index f364c90dec..0045c07540 100644
--- a/packages/app/tests/composables/useSearch.spec.ts
+++ b/packages/app/tests/composables/useSearch.spec.ts
@@ -1,3 +1,5 @@
+import { computed, ref } from "vue";
+
import { describe, expect, it, vi } from "vitest";
import { $fetch } from "ohmyfetch";
@@ -72,6 +74,24 @@ describe("UseSearch:", () => {
const searchRoute = getSearchRoute("123");
expect(searchRoute).toBeNull();
});
+
+ it("routes base token address to token page in prividium mode", () => {
+ const { getSearchRoute } = useSearch({
+ currentNetwork: computed(() => ({
+ prividium: true,
+ baseTokenAddress: "0x000000000000000000000000000000000000800A",
+ apiUrl: "http://localhost:3020",
+ })),
+ user: ref({ loggedIn: false }),
+ } as never);
+
+ const searchRoute = getSearchRoute("0x000000000000000000000000000000000000800A");
+ expect(searchRoute!.apiRoute).toBe("address");
+ expect(searchRoute!.routeName).toBe("token");
+ expect(searchRoute!.routeParam).toEqual({
+ address: "0x000000000000000000000000000000000000800A",
+ });
+ });
});
describe("search", () => {
diff --git a/packages/app/tests/composables/useTransfers.spec.ts b/packages/app/tests/composables/useTransfers.spec.ts
index bd32b7aaea..1c67572294 100644
--- a/packages/app/tests/composables/useTransfers.spec.ts
+++ b/packages/app/tests/composables/useTransfers.spec.ts
@@ -157,4 +157,13 @@ describe("useTransfers:", () => {
expect(composable.data.value).toEqual(null);
mock.mockRestore();
});
+
+ it("requests token transfers endpoint when token mode is enabled", async () => {
+ const composable = useTransfers(address, { forToken: true });
+ await composable.load(1);
+
+ expect($fetch).toHaveBeenCalledWith(
+ "https://block-explorer-api.testnets.zksync.dev/tokens/address/transfers?limit=10&page=1"
+ );
+ });
});