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:
paths:
- "packages/common-testnets/**"
- "pnpm-lock.yaml"
push:
branches:
- main

jobs:
test:
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 check
- run: pnpm verify
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 }}"
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ jobs:
- run: pnpm install

- run: pnpm check
- run: pnpm verify # does linting, type checking, and tests in parallel
- name: Verify # verifies all packages except common-testnets
run: pnpm run --parallel --aggregate-output --reporter append-only --filter './packages/{app,common-nodejs,common-universal}' verify
Copy link
Contributor

Choose a reason for hiding this comment

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

i think we can simplify this. So the mental model should be that verify is fast (linting, unit tests etc) and we can always run in on every single project. In case of common-testnets verify would run eslint and typecheck but no tests because these are not unit tests but rather integration/e2e tests. So the task should be renamed to test-e2e and then the CI job for testnets runs only that.


storybook-visual-regression:
strategy:
Expand Down Expand Up @@ -132,9 +133,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
4 changes: 3 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"lint": "eslint src",
"lint:custom": "tsx ./scripts/verify-svgs.ts",
"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\" \"pnpm run lint:custom\"",
"fix": "cd ../../ && pnpm run check:fix && cd - && pnpm run verify",
"check": "cd ../../ && pnpm run check && cd -",
"check:fix": "cd ../../ && pnpm run check:fix && cd -",
"fix": "pnpm run check:fix && pnpm run verify",
"test": "DEBUG_PRINT_LIMIT=100000 vitest --run",
"test-e2e": "playwright test",
"test-e2e:ui": "pnpm test-e2e --ui --headed",
Expand Down
4 changes: 3 additions & 1 deletion packages/common-nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
"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",
"check": "cd ../../ && pnpm run check && cd -",
"check:fix": "cd ../../ && pnpm run check:fix && cd -",
"fix": "pnpm run check:fix && pnpm run verify",
"test": "mocha src/**/*.test.ts",
"typecheck": "tsc --noEmit",
"build:cjs": "tsc -p tsconfig.cjs.json",
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
}
61 changes: 61 additions & 0 deletions packages/common-testnets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"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\"",
"check": "cd ../../ && pnpm run check && cd -",
"check:fix": "cd ../../ && pnpm run check:fix && cd -",
"fix": "cd ../../ && pnpm run check:fix && cd - && pnpm run verify",
"test": "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",
"zod": "^3.23.8"
},
"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)
})
})
})
}
})
19 changes: 19 additions & 0 deletions packages/common-testnets/src/TestnetFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { TestnetClient } from './TestnetClient'

export interface TestnetCreateResult {
client: TestnetClient
cleanup: () => Promise<void>
}

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 snapshot are "burned" after revert so we need to create a new one
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// anvil snapshot are "burned" after revert so we need to create a new one
// 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