Skip to content
Open
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
6 changes: 6 additions & 0 deletions packages/api/src/address/address.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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>({ address: blockchainAddress, bytecode: "0x123" }));
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/address/address.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -70,14 +71,17 @@ 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;
let includeCreatorTxHash = true;

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();
}

Expand Down
26 changes: 18 additions & 8 deletions packages/app/src/components/Token.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@
</TransactionsTable>
</template>
<template #tab-2-content>
<ContractInfoTab :contract="contract" />
<TransfersTable v-if="isBaseToken" :address="contract.address" :for-token="true" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only for base token?

<ContractInfoTab v-if="!isBaseToken" :contract="contract" />
</template>
<template #tab-3-content>
<template v-if="!isBaseToken" #tab-3-content>
<ContractEvents v-if="showEventsTab" :contract="contract" />
</template>
</Tabs>
Expand All @@ -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";
Expand Down Expand Up @@ -112,6 +114,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isBaseToken: {
type: Boolean,
default: false,
},
});

const { getTokenInfo, tokenInfo } = useToken();
Expand All @@ -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[] | [] => {
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/components/transfers/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@

<TableBodyColumn :data-heading="t('transfers.table.direction')">
<TransactionDirectionTableCell
v-if="!forToken"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

:data-testid="$testId.direction"
class="transfers-in-out"
:text="getTransferDirection(item)"
Expand Down Expand Up @@ -167,12 +168,17 @@ const props = defineProps({
required: true,
default: () => 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 {
Expand All @@ -185,7 +191,7 @@ const toDate = new Date();
watch(
[activePage, () => props.address],
([page]) => {
load(page, toDate);
load(page, props.forToken ? undefined : toDate);
},
{ immediate: true }
);
Expand Down
19 changes: 13 additions & 6 deletions packages/app/src/composables/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this feature something specific to Prividium? I'd imagine that any network with a custom base token would want to show a token-based view.

!!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",
Expand Down
21 changes: 15 additions & 6 deletions packages/app/src/composables/useTransfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ export type Transfer = Api.Response.Transfer & {
toNetwork: NetworkOrigin;
};

export default (address: ComputedRef<string>, context = useContext()) => {
interface UseTransferOptions {
forToken?: boolean;
}

export default (
address: ComputedRef<string>,
{ forToken = false }: UseTransferOptions = {},

@Romsters Romsters Feb 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather add another composable useTokenTransfers(tokenAddress) to make it less confusing.

context = useContext()
) => {
return useFetchCollection<Transfer, Api.Response.Transfer>(
() =>
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 || {
Expand Down
14 changes: 13 additions & 1 deletion packages/app/src/utils/validators.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { AbiCoder, isAddress as ethersIsAddress } from "ethers";
import { AbiCoder, isAddress as ethersIsAddress, getAddress } from "ethers";

const defaultAbiCoder: AbiCoder = AbiCoder.defaultAbiCoder();

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);
};
Expand Down
50 changes: 47 additions & 3 deletions packages/app/src/views/TokenView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
<PageError />
</div>
<template v-else-if="props.address && isAddress(props.address)">
<AccountView v-if="pageType === 'account'" :account="(item as Account)" :pending="pending" :failed="failed" />
<TokenView v-else :contract="(item as Contract)" :pending="pending" :failed="failed" />
<AccountView v-if="!showTokenComponent" :account="(item as Account)" :pending="pending" :failed="failed" />
<TokenView
v-else
:contract="tokenContract"
:pending="pending"
:failed="failed"
:is-base-token="isPrividiumBaseTokenAddress"
/>
</template>
</template>

Expand All @@ -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();

Expand All @@ -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<Contract | null>(() => {
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(() => {
Expand Down
20 changes: 20 additions & 0 deletions packages/app/tests/composables/useSearch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { computed, ref } from "vue";

import { describe, expect, it, vi } from "vitest";

import { $fetch } from "ohmyfetch";
Expand Down Expand Up @@ -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", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/app/tests/composables/useTransfers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});
});
Loading