Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom base token support #273

Merged
merged 11 commits into from
Aug 2, 2024
16 changes: 16 additions & 0 deletions packages/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,19 @@ DISABLE_EXTERNAL_API=false
DATABASE_STATEMENT_TIMEOUT_MS=90000
CONTRACT_VERIFICATION_API_URL=http://127.0.0.1:3070
NETWORK_NAME=testnet-sepolia

BASE_TOKEN_SYMBOL=ETH
BASE_TOKEN_DECIMALS=18
BASE_TOKEN_L1_ADDRESS=0x0000000000000000000000000000000000000000
BASE_TOKEN_ICON_URL=https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266
BASE_TOKEN_NAME=Ether
BASE_TOKEN_LIQUIDITY=220000000000
BASE_TOKEN_USDPRICE=1800

ETH_TOKEN_SYMBOL=ETH
ETH_TOKEN_DECIMALS=18
ETH_TOKEN_L2_ADDRESS=0x000000000000000000000000000000000000800A
ETH_TOKEN_ICON_URL=https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266
ETH_TOKEN_NAME=Ether
ETH_TOKEN_LIQUIDITY=220000000000
ETH_TOKEN_USDPRICE=1800
8 changes: 8 additions & 0 deletions packages/api/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@ PORT=3007
LIMITED_PAGINATION_MAX_ITEMS=15
API_LIMITED_PAGINATION_MAX_ITEMS=15
CONTRACT_VERIFICATION_API_URL=http://verification.api
BASE_TOKEN_SYMBOL=ETH
BASE_TOKEN_DECIMALS=18
BASE_TOKEN_L1_ADDRESS=0x0000000000000000000000000000000000000000
BASE_TOKEN_ICON_URL=https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266
BASE_TOKEN_NAME=Ether
BASE_TOKEN_LIQUIDITY=220000000000
BASE_TOKEN_USDPRICE=1800
ETH_TOKEN_L2_ADDRESS=0x000000000000000000000000000000000000800A
55 changes: 55 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,61 @@ You need to have a running Worker database, for instructions on how to run the w
- `DATABASE_CONNECTION_POOL_SIZE`
- Set `CONTRACT_VERIFICATION_API_URL` to your verification API URL. For zkSync Era testnet use `https://zksync2-testnet-explorer.zksync.dev`. For zkSync Era mainnet - `https://zksync2-mainnet-explorer.zksync.io`.

## Custom base token configuration
For networks with a custom base token, there are a number of environment variables used to configure custom base and ETH tokens:
- `BASE_TOKEN_L1_ADDRESS` - required, example: `0xB44A106F271944fEc1c27cd60b8D6C8792df86d8`. Base token L1 address can be fetched using the RPC call:
```
curl http://localhost:3050 \
-X POST \
-H "Content-Type: application/json" \
--data '{"method":"zks_getBaseTokenL1Address","params":[],"id":1,"jsonrpc":"2.0"}'
```
or SDK:
```
import { Provider } from "zksync-ethers";
async function main() {
const l2provider = new Provider("http://localhost:3050");
const baseTokenAddress = await l2provider.getBaseTokenContractAddress();
console.log('baseTokenAddress', baseTokenAddress);
}
main()
.then()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
- `BASE_TOKEN_SYMBOL` - required, example: `ZK`
- `BASE_TOKEN_NAME` - required, example: `ZK`
- `BASE_TOKEN_DECIMALS` - required, example: `18`
- `BASE_TOKEN_LIQUIDITY` - optional, example: `20000`
- `BASE_TOKEN_ICON_URL` - optional, example: `https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266`
- `BASE_TOKEN_USDPRICE` - optional, example: `3300.30`.

- `ETH_TOKEN_L2_ADDRESS` - required, example: `0x642C0689b87dEa060B9f0E2e715DaB8564840861`. Eth L2 address can be calculated using SDK:
```
import { utils, Provider } from "zksync-ethers";
async function main() {
const l2provider = new Provider("http://localhost:3050");
const ethL2Address = await l2provider.l2TokenAddress(utils.ETH_ADDRESS);
console.log('ethL2Address', ethL2Address);
}
main()
.then()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
- `ETH_TOKEN_NAME` - optional, default is `Ether`
- `ETH_TOKEN_SYMBOL` - optional, default is `ETH`
- `ETH_TOKEN_DECIMALS` - optional, default is `18`
- `ETH_TOKEN_LIQUIDITY` - optional, example: `20000`
- `ETH_TOKEN_ICON_URL` - optional, default (ETH icon) is: `https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266`
- `ETH_TOKEN_USDPRICE` - optional, example: `3300.30`.

## Running the app

```bash
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/api/account/account.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Test } from "@nestjs/testing";
import { mock } from "jest-mock-extended";
import { BadRequestException, Logger } from "@nestjs/common";
import { L2_ETH_TOKEN_ADDRESS } from "../../common/constants";
import { BASE_TOKEN_L2_ADDRESS } from "../../common/constants";
import { BlockService } from "../../block/block.service";
import { BlockDetails } from "../../block/blockDetails.entity";
import { TransactionService } from "../../transaction/transaction.service";
Expand Down Expand Up @@ -557,7 +557,7 @@ describe("AccountController", () => {
describe("getAccountEtherBalance", () => {
it("calls balanceService.getBalance and returns account ether balance", async () => {
const response = await controller.getAccountEtherBalance(address);
expect(balanceServiceMock.getBalance).toBeCalledWith(address, L2_ETH_TOKEN_ADDRESS);
expect(balanceServiceMock.getBalance).toBeCalledWith(address, BASE_TOKEN_L2_ADDRESS);
expect(response).toEqual({
status: ResponseStatus.OK,
message: ResponseMessage.OK,
Expand Down Expand Up @@ -588,7 +588,7 @@ describe("AccountController", () => {

it("calls balanceService.getBalancesByAddresses and returns accounts ether balances", async () => {
const response = await controller.getAccountsEtherBalances([address, "address2"]);
expect(balanceServiceMock.getBalancesByAddresses).toBeCalledWith([address, "address2"], L2_ETH_TOKEN_ADDRESS);
expect(balanceServiceMock.getBalancesByAddresses).toBeCalledWith([address, "address2"], BASE_TOKEN_L2_ADDRESS);
expect(response).toEqual({
status: ResponseStatus.OK,
message: ResponseMessage.OK,
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/api/account/account.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Controller, Get, Query, Logger, UseFilters, ParseArrayPipe, BadRequestException } from "@nestjs/common";
import { ApiTags, ApiExcludeController } from "@nestjs/swagger";
import { L2_ETH_TOKEN_ADDRESS } from "../../common/constants";
import { BASE_TOKEN_L2_ADDRESS } from "../../common/constants";
import { TokenType } from "../../token/token.entity";
import { dateToTimestamp } from "../../common/utils";
import { BlockService } from "../../block/block.service";
Expand Down Expand Up @@ -176,7 +176,7 @@ export class AccountController {
public async getAccountEtherBalance(
@Query("address", new ParseAddressPipe()) address: string
): Promise<AccountEtherBalanceResponseDto> {
const balance = await this.balanceService.getBalance(address, L2_ETH_TOKEN_ADDRESS);
const balance = await this.balanceService.getBalance(address, BASE_TOKEN_L2_ADDRESS);
return {
status: ResponseStatus.OK,
message: ResponseMessage.OK,
Expand All @@ -201,7 +201,7 @@ export class AccountController {
if (uniqueAddresses.length > 20) {
throw new BadRequestException("Maximum 20 addresses per request");
}
const balances = await this.balanceService.getBalancesByAddresses(addresses, L2_ETH_TOKEN_ADDRESS);
const balances = await this.balanceService.getBalancesByAddresses(addresses, BASE_TOKEN_L2_ADDRESS);
const result = addresses.map((address) => ({
account: address,
balance: balances.find((balance) => balance.address.toLowerCase() === address.toLowerCase())?.balance || "0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Transfer } from "../../transfer/transfer.entity";
import { TransactionStatus } from "../../transaction/entities/transaction.entity";
import { L2_ETH_TOKEN_ADDRESS } from "../../common/constants";
import { BASE_TOKEN_L2_ADDRESS } from "../../common/constants";
import { mapInternalTransactionListItem } from "./internalTransactionMapper";

describe("internalTransactionMapper", () => {
Expand All @@ -11,7 +11,7 @@ describe("internalTransactionMapper", () => {
from: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35C",
to: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35D",
amount: "1000000",
tokenAddress: L2_ETH_TOKEN_ADDRESS,
tokenAddress: BASE_TOKEN_L2_ADDRESS,
transaction: {
blockNumber: 20,
receivedAt: new Date("2023-01-01"),
Expand Down
18 changes: 15 additions & 3 deletions packages/api/src/api/stats/stats.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { Test } from "@nestjs/testing";
import { mock } from "jest-mock-extended";
import { ConfigService } from "@nestjs/config";
import { Logger } from "@nestjs/common";
import { TokenService } from "../../token/token.service";
import { Token, ETH_TOKEN } from "../../token/token.entity";
import { Token } from "../../token/token.entity";
import { StatsController } from "./stats.controller";
import { BASE_TOKEN_L2_ADDRESS } from "../../common/constants";

describe("StatsController", () => {
let controller: StatsController;
let tokenServiceMock: TokenService;
let configServiceMock: ConfigService;

beforeEach(async () => {
tokenServiceMock = mock<TokenService>({
findOne: jest.fn().mockResolvedValue(null),
});
configServiceMock = mock<ConfigService>({
get: jest.fn().mockResolvedValue({
l2Address: BASE_TOKEN_L2_ADDRESS,
}),
});

const module = await Test.createTestingModule({
controllers: [StatsController],
Expand All @@ -21,6 +29,10 @@ describe("StatsController", () => {
provide: TokenService,
useValue: tokenServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
}).compile();
module.useLogger(mock<Logger>());
Expand All @@ -31,7 +43,7 @@ describe("StatsController", () => {
describe("ethPrice", () => {
it("returns ok response and ETH price when ETH token is found", async () => {
jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce({
usdPrice: ETH_TOKEN.usdPrice,
usdPrice: 1000,
offChainDataUpdatedAt: new Date("2023-03-03"),
} as Token);

Expand All @@ -40,7 +52,7 @@ describe("StatsController", () => {
status: "1",
message: "OK",
result: {
ethusd: ETH_TOKEN.usdPrice.toString(),
ethusd: "1000",
ethusd_timestamp: Math.floor(new Date("2023-03-03").getTime() / 1000).toString(),
},
});
Expand Down
14 changes: 11 additions & 3 deletions packages/api/src/api/stats/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Controller, Get, UseFilters } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags, ApiExcludeController } from "@nestjs/swagger";
import { ResponseStatus, ResponseMessage } from "../dtos/common/responseBase.dto";
import { ApiExceptionFilter } from "../exceptionFilter";
import { EthPriceResponseDto } from "../dtos/stats/ethPrice.dto";
import { TokenService } from "../../token/token.service";
import { ETH_TOKEN } from "../../token/token.entity";
import { dateToTimestamp } from "../../common/utils";
import { type BaseToken } from "../../config";

const entityName = "stats";

Expand All @@ -14,11 +15,18 @@ const entityName = "stats";
@Controller(`api/${entityName}`)
@UseFilters(ApiExceptionFilter)
export class StatsController {
constructor(private readonly tokenService: TokenService) {}
private readonly ethTokenAddress: string;

constructor(private readonly tokenService: TokenService, private readonly configService: ConfigService) {
this.ethTokenAddress = this.configService.get<BaseToken>("ethToken").l2Address;
}

@Get("/ethprice")
public async ethPrice(): Promise<EthPriceResponseDto> {
const token = await this.tokenService.findOne(ETH_TOKEN.l2Address, { usdPrice: true, offChainDataUpdatedAt: true });
const token = await this.tokenService.findOne(this.ethTokenAddress, {
usdPrice: true,
offChainDataUpdatedAt: true,
});
return {
status: token ? ResponseStatus.OK : ResponseStatus.NOTOK,
message: token ? ResponseMessage.OK : ResponseMessage.NO_DATA_FOUND,
Expand Down
31 changes: 20 additions & 11 deletions packages/api/src/api/token/token.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Test } from "@nestjs/testing";
import { mock } from "jest-mock-extended";
import { Logger } from "@nestjs/common";
import { TokenService } from "../../token/token.service";
import { Token, ETH_TOKEN } from "../../token/token.entity";
import { Token } from "../../token/token.entity";
import { TokenController } from "./token.controller";

describe("TokenController", () => {
Expand Down Expand Up @@ -32,22 +32,31 @@ describe("TokenController", () => {

describe("tokenInfo", () => {
it("returns ok response and token info when token is found", async () => {
jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce(ETH_TOKEN);

const baseToken = {
l2Address: "l2Address",
l1Address: "l1Address",
symbol: "ETH",
name: "Ether",
decimals: 18,
liquidity: 10,
iconURL: "iconURL",
usdPrice: 20,
} as Token;
jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce(baseToken);
const response = await controller.tokenInfo(contractAddress);
expect(response).toEqual({
status: "1",
message: "OK",
result: [
{
contractAddress: ETH_TOKEN.l2Address,
iconURL: ETH_TOKEN.iconURL,
l1Address: ETH_TOKEN.l1Address,
liquidity: ETH_TOKEN.liquidity.toString(),
symbol: ETH_TOKEN.symbol,
tokenDecimal: ETH_TOKEN.decimals.toString(),
tokenName: ETH_TOKEN.name,
tokenPriceUSD: ETH_TOKEN.usdPrice.toString(),
contractAddress: baseToken.l2Address,
iconURL: baseToken.iconURL,
l1Address: baseToken.l1Address,
liquidity: baseToken.liquidity.toString(),
symbol: baseToken.symbol,
tokenDecimal: baseToken.decimals.toString(),
tokenName: baseToken.name,
tokenPriceUSD: baseToken.usdPrice.toString(),
},
],
});
Expand Down
1 change: 0 additions & 1 deletion packages/api/src/api/token/token.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ResponseStatus, ResponseMessage } from "../dtos/common/responseBase.dto
import { ApiExceptionFilter } from "../exceptionFilter";
import { TokenInfoResponseDto } from "../dtos/token/tokenInfo.dto";
import { TokenService } from "../../token/token.service";

const entityName = "token";

@ApiExcludeController()
Expand Down
15 changes: 11 additions & 4 deletions packages/api/src/balance/balance.entity.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Entity, Column, PrimaryColumn, Index, ManyToOne, JoinColumn, AfterLoad } from "typeorm";
import { BaseEntity } from "../common/entities/base.entity";
import { Token, ETH_TOKEN } from "../token/token.entity";
import { Token } from "../token/token.entity";
import { normalizeAddressTransformer } from "../common/transformers/normalizeAddress.transformer";
import { bigIntNumberTransformer } from "../common/transformers/bigIntNumber.transformer";
import { baseToken, ethToken } from "../config";

@Entity({ name: "balances" })
export class Balance extends BaseEntity {
Expand All @@ -24,9 +25,15 @@ export class Balance extends BaseEntity {
public readonly balance: string;

@AfterLoad()
populateEthToken() {
if (this.tokenAddress === ETH_TOKEN.l2Address && !this.token) {
this.token = ETH_TOKEN;
populateBaseToken() {
// tokenAddress might be empty when not all entity fields are requested from the DB
if (this.tokenAddress && !this.token) {
const tokenAddress = this.tokenAddress.toLowerCase();
if (tokenAddress === baseToken.l2Address.toLowerCase()) {
this.token = baseToken as Token;
} else if (tokenAddress === ethToken.l2Address.toLowerCase()) {
this.token = ethToken as Token;
}
}
}
}
3 changes: 2 additions & 1 deletion packages/api/src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const L2_ETH_TOKEN_ADDRESS = "0x000000000000000000000000000000000000800a";
export const BASE_TOKEN_L2_ADDRESS = "0x000000000000000000000000000000000000800A";
export const BASE_TOKEN_L1_ADDRESS = "0x0000000000000000000000000000000000000000";
Loading
Loading