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

Add common-testnets package #489

Merged
merged 13 commits into from
Dec 13, 2024
43 changes: 43 additions & 0 deletions .github/workflows/ci-common-testnets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI (common-testnets)

on:
pull_request:
# @todo: uncomment when testnet factories are stable
# paths:
# - "packages/common-testnets/**"
# - "pnpm-lock.yaml"
push:
branches:
- main

jobs:
test-e2e:
strategy:
matrix:
node: [ "20" ]
os: [ ubuntu-latest ]
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.14.2
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
- run: pnpm install

- name: Install Foundry
uses: foundry-rs/[email protected]
with:
version: nightly-58bf161bc9dd6e74de8cb61e3ae23f701feb5512

- run: pnpm test-e2e
working-directory: ./packages/common-testnets
env:
TENDERLY_API_KEY: "${{ secrets.TENDERLY_API_KEY }}"
TENDERLY_ACCOUNT: phoenixlabs
TENDERLY_PROJECT: spark-app-e2e-tests
TEST_E2E_ALCHEMY_API_KEY: "${{ secrets.ALCHEMY_API_KEY }}"
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
- run: pnpm install

- run: pnpm check
- run: pnpm verify # does linting, type checking, and tests in parallel
- run: pnpm verify # runs linting, type checking, and unit tests in parallel

storybook-visual-regression:
strategy:
Expand Down Expand Up @@ -132,9 +132,7 @@ jobs:
env:
TENDERLY_API_KEY: "${{ secrets.TENDERLY_API_KEY }}"
TENDERLY_ACCOUNT: phoenixlabs
TENDERLY_PROJECT: sparklend
TENDERLY_BASE_DEVNET_CONTAINER_ID: "${{ secrets.TENDERLY_BASE_DEVNET_CONTAINER_ID }}"
VITE_DEV_BASE_DEVNET_RPC_URL: "${{ secrets.VITE_DEV_BASE_DEVNET_RPC_URL }}"
TENDERLY_PROJECT: spark-app-e2e-tests
PLAYWRIGHT_TRACE: 1
- name: Upload report to GitHub Actions Artifacts
if: failure()
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"clean": "pnpm run --parallel --aggregate-output --reporter append-only --filter './packages/**' clean"
},
"dependencies": {
"bignumber.js": "^9.1.2"
"bignumber.js": "^9.1.2",
"zod": "3.22.4"
},
"devDependencies": {
"@biomejs/biome": "^1.8.1",
Expand Down
1 change: 0 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"tailwindcss-animate": "^1.0.6",
"viem": "^2.9.21",
"wagmi": "^2.5.20",
"zod": "^3.22.4",
"zustand": "^4.4.1"
},
"devDependencies": {
Expand Down
7 changes: 7 additions & 0 deletions packages/common-testnets/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/mocharc",
"extension": ["ts"],
"require": "dotenv/config",
"node-option": ["import=tsx", "conditions=@marsfoundation/local-spark-monorepo"],
"timeout": 60000
}
59 changes: 59 additions & 0 deletions packages/common-testnets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@marsfoundation/common-testnets",
"version": "0.0.1",
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/marsfoundation/spark-app.git",
"directory": "packages/common-testnets"
},
"exports": {
".": {
"import": {
"@marsfoundation/local-spark-monorepo": "./src/index.ts",
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"@marsfoundation/local-spark-monorepo": "./src/index.ts",
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
}
},
"files": ["dist"],
"scripts": {
"lint": "eslint src",
"verify": "concurrently --names \"LINT,TEST,TYPECHECK,LINT-CUSTOM\" -c \"bgMagenta.bold,bgGreen.bold,bgBlue.bold,bgCyan.bold\" \"pnpm run lint\" \"pnpm run test --silent\" \"pnpm run typecheck\"",
"fix": "cd ../../ && pnpm run check:fix && cd - && pnpm run verify",
"test": "true",
"test-e2e": "mocha src/**/*.test.ts",
"typecheck": "tsc --noEmit",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build": "pnpm run build:cjs && pnpm run build:esm",
"clean": "rm -rf dist",
"prepublishOnly": "pnpm run clean && pnpm run build"
},
"dependencies": {
"@marsfoundation/common-nodejs": "workspace:^",
"@marsfoundation/common-universal": "workspace:^",
"@viem/anvil": "^0.0.10",
"get-port": "^7.1.0",
"viem": "2.21.18",
"viem-deal": "^2.0.0"
},
"devDependencies": {
"@types/mocha": "^10.0.10",
"@types/uuid": "^10.0.0",
"earl": "^1.3.0",
"mocha": "^10.8.2",
"uuid": "^11.0.2"
}
}
15 changes: 15 additions & 0 deletions packages/common-testnets/src/TestnetClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { raise } from '@marsfoundation/common-universal'
import { Address, PublicActions, WalletClient } from 'viem'

export interface TestnetClient extends WalletClient, PublicActions {
setErc20Balance(tkn: Address, usr: Address, amount: bigint): Promise<void>
setBalance(usr: Address, amount: bigint): Promise<void>
snapshot(): Promise<string>
revert(snapshotId: string): Promise<string> // @note: returns new snapshot id (may be the same as the input)
mineBlocks(blocks: bigint): Promise<void>
setNextBlockTimestamp(timestamp: bigint): Promise<void>
}

export function getUrlFromClient(client: TestnetClient): string {
return client.transport.type === 'http' ? client.transport.url : raise('Only http transport is supported')
}
106 changes: 106 additions & 0 deletions packages/common-testnets/src/TestnetFactory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getEnv } from '@marsfoundation/common-nodejs/env'
import { expect } from 'earl'
import { after, before, describe, it } from 'mocha'
import { TestnetClient } from './TestnetClient'
import { TestnetFactory } from './TestnetFactory'
import { AnvilTestnetFactory } from './anvil'
import { TenderlyTestnetFactory } from './tenderly'

const env = getEnv()

describe('TestnetFactory', () => {
const factories: TestnetFactory[] = [
new TenderlyTestnetFactory({
apiKey: env.string('TENDERLY_API_KEY'),
account: env.string('TENDERLY_ACCOUNT'),
project: env.string('TENDERLY_PROJECT'),
}),
new AnvilTestnetFactory({ alchemyApiKey: env.string('TEST_E2E_ALCHEMY_API_KEY') }),
]

for (const factory of factories) {
describe(factory.constructor.name, () => {
const expectedChainId = 2137

describe('With block specified', () => {
let testnetClient: TestnetClient
let cleanup: () => Promise<void>
const blockNumber = 21378357n
const expectedTimestamp = 1733909123n + 2n

before(async () => {
;({ client: testnetClient, cleanup } = await factory.create({
id: 'test',
originChainId: 1,
forkChainId: expectedChainId,
blockNumber,
}))
})

after(async () => {
await cleanup()
})

it('creates a testnet with desired block', async () => {
const currentBlockNumber = await testnetClient.getBlockNumber()

expect(currentBlockNumber).toEqual(blockNumber + 2n)
})

it('creates a testnet with desired timestamp', async () => {
const currentBlockNumber = await testnetClient.getBlockNumber()
const currentBlock = await testnetClient.getBlock({ blockNumber: currentBlockNumber })

expect(currentBlock.timestamp).toEqual(expectedTimestamp)
})

it('creates a testnet with desired chainId', async () => {
const chainId = await testnetClient.getChainId()

expect(chainId).toEqual(expectedChainId)
})

it('contains tx from a top of the forked blockchain', async () => {
const receipt = await testnetClient.getTransactionReceipt({
hash: '0x3c66f14cafc6b806538d97953be5bf4775be4f851448e937a612007c9e207c37',
})

expect(receipt).toEqual(
expect.subset({
status: 'success',
}),
)
})

it('does not contain tx from future block', async () => {
await expect(
testnetClient.getTransactionReceipt({
hash: '0x7057abf025862e54cb1c33b4f4d4e6f8793383098abb84e58a85d1f10d14b765',
}),
).toBeRejectedWith('The Transaction may not be processed on a block yet.')
})
})

describe('Without block specified', () => {
let testnetClient: TestnetClient
let cleanup: () => Promise<void>

before(async () => {
;({ client: testnetClient, cleanup } = await factory.create({
id: 'test',
originChainId: 1,
forkChainId: expectedChainId,
}))
})
after(async () => {
await cleanup()
})

it('can fetch block number', async () => {
const currentBlockNumber = await testnetClient.getBlockNumber()
expect(currentBlockNumber).toBeGreaterThan(21385842n)
})
})
})
}
})
23 changes: 23 additions & 0 deletions packages/common-testnets/src/TestnetFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { TestnetClient } from './TestnetClient'

export interface TestnetCreateResult {
client: TestnetClient
cleanup: () => Promise<void>
}
/**
* The created testnet will have a small, though known beforehand, difference in both the final block number
* and its timestamp compared to the requested block. This is due to necessary normalization
* steps that ensure compatibility with different client implementations.
*/
export interface TestnetFactory {
krzkaczor marked this conversation as resolved.
Show resolved Hide resolved
create(network: CreateNetworkArgs): Promise<TestnetCreateResult>
createClientFromUrl(rpcUrl: string): TestnetClient
}

export interface CreateNetworkArgs {
id: string
displayName?: string
originChainId: number
forkChainId: number
blockNumber?: bigint
}
61 changes: 61 additions & 0 deletions packages/common-testnets/src/anvil/AnvilClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { assert } from '@marsfoundation/common-universal'
import { http, Address, createTestClient, numberToHex, publicActions, walletActions } from 'viem'
import { dealActions } from 'viem-deal'
import { mainnet } from 'viem/chains'
import { TestnetClient } from '../TestnetClient'

export function getAnvilClient(rpc: string): TestnetClient {
return createTestClient({
chain: mainnet,
mode: 'anvil',
transport: http(rpc),
cacheTime: 0, // do not cache block numbers
})
.extend(publicActions)
.extend(dealActions)
.extend((c) => ({
async setErc20Balance(tkn: Address, usr: Address, amt: bigint): Promise<void> {
return await c.deal({
erc20: tkn.toLowerCase() as any,
account: usr.toLowerCase() as any,
amount: amt,
})
},
async setBalance(usr: Address, amt: bigint): Promise<void> {
return c.request({
method: 'anvil_setBalance',
params: [usr.toString(), `0x${amt.toString(16)}`],
} as any)
},
async snapshot(): Promise<string> {
return c.request({
method: 'evm_snapshot',
params: [],
} as any)
},
async revert(snapshotId: string) {
const result = await c.request({
method: 'evm_revert',
params: [snapshotId],
} as any)

assert(result === true, 'revert failed')

// anvil snapshots are "burned" after revert so we need to create a new one
return await c.snapshot()
},
async mineBlocks(blocks: bigint) {
await c.request({
method: 'anvil_mine',
params: [numberToHex(blocks), '0x1'],
})
},
async setNextBlockTimestamp(timestamp: bigint) {
await c.request({
method: 'evm_setNextBlockTimestamp',
params: [numberToHex(timestamp)],
})
},
}))
.extend(walletActions)
}
Loading
Loading