Skip to content
Merged
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
"publish:results": "tsx scripts/publishResults.ts",
"release": "pnpm changeset publish",
"spec": "vitest --project spec",
"test:client": "vitest --project client",
"test:react": "vitest --project react",
"test": "pnpm test:react",
"test": "pnpm test:client && pnpm test:react",
"typecheck:spec": "pnpm --filter spec typecheck"
},
"license": "MIT",
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"@bgd-labs/aave-address-book": "^4.25.3",
"@privy-io/server-auth": "^1.28.8",
"ethers": "^6.14.4",
"msw": "^2.10.5",
"thirdweb": "^5.105.25",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
Expand Down
104 changes: 104 additions & 0 deletions packages/client/src/rpc.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { type HexString, invariant, isObject } from '@aave/types-next';
import { type HttpResponse, http, type PathParams, passthrough } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, beforeAll } from 'vitest';
import { ETHEREUM_FORK_RPC_URL } from './test-utils';

export type JsonRpcId = number | string | null;

export interface JsonRpcRequest<M extends string, P = unknown> {
jsonrpc: '2.0';
id: JsonRpcId;
method: M;
params: P;
}

export interface JsonRpcSuccess<T = unknown> {
jsonrpc: '2.0';
id: JsonRpcId;
result: T;
}

export interface JsonRpcError<E = unknown> {
jsonrpc: '2.0';
id: JsonRpcId;
error: {
code: number;
message: string;
data?: E;
};
}

export type JsonRpcResponse<T = unknown, E = unknown> =
| JsonRpcSuccess<T>
| JsonRpcError<E>;

export type AnyOtherJsonRpcRequest = JsonRpcRequest<'__ANY_METHOD__'>;

export type EthChainIdRequest = JsonRpcRequest<'eth_chainId', []>;

export type WalletSwitchEthereumChainRequest = JsonRpcRequest<
'wallet_switchEthereumChain',
[{ chainId: HexString }]
>;

export type WalletAddEthereumChainRequest = JsonRpcRequest<
'wallet_addEthereumChain',
[
{
chainId: HexString;
chainName?: string;
nativeCurrency?: {
name: string;
symbol: string;
decimals: number;
};
rpcUrls?: string[];
blockExplorerUrls?: string[];
iconUrls?: string[];
},
]
>;

export type SupportedJsonRpcRequest =
| EthChainIdRequest
| WalletSwitchEthereumChainRequest
| WalletAddEthereumChainRequest
| AnyOtherJsonRpcRequest;

function isJsonRpcRequest(body: unknown): body is SupportedJsonRpcRequest {
return isObject(body) && 'jsonrpc' in body && 'method' in body;
}

export function setupRpcInterceptor(
handler: (
body: SupportedJsonRpcRequest,
) => HttpResponse<JsonRpcResponse> | undefined,
) {
const server = setupServer(
http.post<PathParams, SupportedJsonRpcRequest>(
ETHEREUM_FORK_RPC_URL,
async ({ request }) => {
const body = await request.clone().json();

invariant(
isJsonRpcRequest(body),
'body is not a valid JSON-RPC request',
);

return handler(body);
},
),
http.post(ETHEREUM_FORK_RPC_URL, () => passthrough()),
);

beforeAll(() => {
server.listen();
});

afterAll(() => {
server.close();
});

return server;
}
18 changes: 13 additions & 5 deletions packages/client/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
ResultAsync,
} from '@aave/types-next';
import {
type Account,
type Chain,
createPublicClient,
createWalletClient,
defineChain,
http,
parseEther,
parseUnits,
type Transport,
type WalletClient,
} from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
Expand Down Expand Up @@ -64,9 +66,11 @@ export const ETHEREUM_SPOKE_GOB_ADDRESS = evmAddress(

export const ETHEREUM_MARKET_ETH_CORRELATED_EMODE_CATEGORY = 1;

const ETHEREUM_FORK_RPC_URL = import.meta.env.ETHEREUM_TENDERLY_PUBLIC_RPC;
export const ETHEREUM_FORK_RPC_URL = import.meta.env
.ETHEREUM_TENDERLY_PUBLIC_RPC;

const ETHEREUM_FORK_RPC_URL_ADMIN = import.meta.env.ETHEREUM_TENDERLY_ADMIN_RPC;
export const ETHEREUM_FORK_RPC_URL_ADMIN = import.meta.env
.ETHEREUM_TENDERLY_ADMIN_RPC;

export const ethereumForkChain: Chain = defineChain({
id: ETHEREUM_FORK_ID,
Expand All @@ -93,13 +97,17 @@ export const client = AaveClient.create({
},
});

export function createNewWallet(privateKey?: `0x${string}`): WalletClient {
const privateKeyToUse = privateKey ?? generatePrivateKey();
export async function createNewWallet(
privateKey: `0x${string}` = generatePrivateKey(),
): Promise<WalletClient<Transport, Chain, Account>> {
const wallet = createWalletClient({
account: privateKeyToAccount(privateKeyToUse),
account: privateKeyToAccount(privateKey),
chain: ethereumForkChain,
transport: http(),
});

await fundNativeAddress(evmAddress(wallet.account.address));

return wallet;
}

Expand Down
218 changes: 218 additions & 0 deletions packages/client/src/viem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { CancelError, SigningError } from '@aave/core-next';
import type { TransactionRequest } from '@aave/graphql-next';
import {
assertErr,
assertOk,
type BlockchainData,
chainId,
evmAddress,
} from '@aave/types-next';
import { HttpResponse } from 'msw';
import {
MethodNotSupportedRpcError,
SwitchChainError,
UserRejectedRequestError,
} from 'viem';
import { describe, expect, it } from 'vitest';
import { setupRpcInterceptor } from './rpc.helpers';
import { createNewWallet } from './test-utils';
import { sendWith } from './viem';

const walletClient = await createNewWallet();

describe(`Given a viem's WalletClient instance`, () => {
describe(`And the '${sendWith.name}' handler is used to send a TransactionRequest`, () => {
const request: TransactionRequest = {
__typename: 'TransactionRequest',
to: evmAddress(walletClient.account.address),
from: evmAddress(walletClient.account.address),
data: '0x' as BlockchainData,
value: 0n,
chainId: chainId(1),
operations: [],
};

describe('When the wallet is on a different chain than the TransactionRequest chain', () => {
let walletChainId = `0x${(42).toString(16)}`;

setupRpcInterceptor((request) => {
switch (request.method) {
case 'wallet_switchEthereumChain':
walletChainId = request.params[0].chainId;
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: null,
});

case 'eth_chainId':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: walletChainId,
});
}
return;
});

it('Then it should switch the chain and continue', async () => {
const result = await sendWith(walletClient)(request);

assertOk(result);
});
});

describe('When the wallet does not support the TransactionRequest chain', () => {
let walletChainId = `0x${(42).toString(16)}`;

setupRpcInterceptor((request) => {
switch (request.method) {
case 'wallet_switchEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: SwitchChainError.code,
message: 'Unrecognized chain ID',
},
});

case 'wallet_addEthereumChain':
walletChainId = request.params[0].chainId;
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: null,
});

case 'eth_chainId':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: walletChainId,
});
}
return;
});

it('Then it should add the chain to the wallet and continue', async () => {
const result = await sendWith(walletClient)(request);

assertOk(result);
});
});

describe('When the wallet fails to add the chain to the wallet', () => {
setupRpcInterceptor((request) => {
switch (request.method) {
case 'wallet_switchEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: SwitchChainError.code,
message: 'Unrecognized chain ID',
},
});

case 'wallet_addEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: MethodNotSupportedRpcError.code,
message: 'Resource not available',
},
});

case 'eth_chainId':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: `0x${(42).toString(16)}`,
});
}
return;
});

it('Then it should fail with a SigningError', async () => {
const result = await sendWith(walletClient)(request);

assertErr(result);
expect(result.error).toBeInstanceOf(SigningError);
});
});

describe('When the user rejects the add chain request in their wallet', () => {
setupRpcInterceptor((request) => {
switch (request.method) {
case 'wallet_switchEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: SwitchChainError.code,
message: 'Unrecognized chain ID',
},
});

case 'wallet_addEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: UserRejectedRequestError.code,
message: 'User rejected the request.',
},
});

case 'eth_chainId':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: `0x${(42).toString(16)}`,
});
}
return;
});

it('Then it should fail with a CancelError', async () => {
const result = await sendWith(walletClient)(request);

assertErr(result);
expect(result.error).toBeInstanceOf(CancelError);
});
});

describe(`When the wallet does not support 'wallet_switchEthereumChain'`, () => {
setupRpcInterceptor((request) => {
switch (request.method) {
case 'wallet_switchEthereumChain':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: MethodNotSupportedRpcError.code,
message: 'method wallet_switchEthereumChain not supported',
},
});

case 'eth_chainId':
return HttpResponse.json({
jsonrpc: '2.0',
id: request.id,
result: `0x${(42).toString(16)}`,
});
}
return;
});

it('Then it should fail with a SigningError', async () => {
const result = await sendWith(walletClient)(request);

assertErr(result);
expect(result.error).toBeInstanceOf(SigningError);
});
});
});
});
Loading