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
90 changes: 90 additions & 0 deletions packages/evm/src/contracts/erc721/ERC721ContractClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { erc721Abi, type Address, type Hash } from 'viem';
import { ContractClientBase, type ContractClientBaseOptions } from '../ContractClientBase';
import type { ERC721TokenMetadata } from '../types';

export type ERC721ContractClientOptions = Omit<ContractClientBaseOptions, 'abi'>;

export class ERC721ContractClient extends ContractClientBase<typeof erc721Abi> {
constructor(options: ERC721ContractClientOptions) {
super({ ...options, abi: erc721Abi });
}

async name() {
const result = await this.readContract({ functionName: 'name' });
return result as string;
}

async symbol() {
const result = await this.readContract({ functionName: 'symbol' });
return result as string;
}

async totalSupply() {
const result = await this.readContract({ functionName: 'totalSupply' });
return result as bigint;
}

async balanceOf(owner: Address) {
const result = await this.readContract({ functionName: 'balanceOf', args: [owner] });
return result as bigint;
}

async ownerOf(tokenId: bigint) {
const result = await this.readContract({ functionName: 'ownerOf', args: [tokenId] });
return result as Address;
}

async approve(to: Address, tokenId: bigint) {
const result = await this.simulateAndWriteContract({ functionName: 'approve', args: [to, tokenId] });
return result as Hash;
}

async setApprovalForAll(operator: Address, approved: boolean) {
const result = await this.simulateAndWriteContract({
functionName: 'setApprovalForAll',
args: [operator, approved],
});
return result as Hash;
}

async transferFrom(from: Address, to: Address, tokenId: bigint) {
const result = await this.simulateAndWriteContract({
functionName: 'transferFrom',
args: [from, to, tokenId],
});
return result as Hash;
}

async safeTransferFrom(from: Address, to: Address, tokenId: bigint, data?: `0x${string}`) {
const args = data ? [from, to, tokenId, data] : [from, to, tokenId];
const result = await this.simulateAndWriteContract({
functionName: 'safeTransferFrom',
args,
});
return result as Hash;
}

async tokenURI(tokenId: bigint) {
const result = await this.readContract({ functionName: 'tokenURI', args: [tokenId] });
return result as string;
}

async tokenMetadata() {
const baseParams = { address: this.contractAddress, abi: this.abi };
const multicallFunctions: { functionName: string; args?: unknown[] }[] = [
{ functionName: 'name' },
{ functionName: 'symbol' },
{ functionName: 'totalSupply' },
];
const [name, symbol, totalSupply] = await this.multicall({
contracts: multicallFunctions.map((item) => ({
...baseParams,
functionName: item.functionName,
args: item.args,
})),
allowFailure: false,
});

return { name, symbol, totalSupply } as ERC721TokenMetadata;
}
}
6 changes: 6 additions & 0 deletions packages/evm/src/contracts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ export type ERC20TokenMetadata = {
totalSupply: bigint;
decimals: number;
};

export type ERC721TokenMetadata = {
name: string;
symbol: string;
totalSupply: bigint;
};
3 changes: 2 additions & 1 deletion packages/evm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { verifyEIP712Message, verifyMessage } from './utils';
// contracts
export { ContractClientBase, type ContractClientBaseOptions } from './contracts/ContractClientBase';
export { ERC20ContractClient, type ERC20ContractClientOptions } from './contracts/erc20/ERC20ContractClient';
export { type ERC20TokenMetadata } from './contracts/types';
export { ERC721ContractClient, type ERC721ContractClientOptions } from './contracts/erc721/ERC721ContractClient';
export { type ERC20TokenMetadata, type ERC721TokenMetadata } from './contracts/types';
27 changes: 25 additions & 2 deletions packages/evm/tests/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { sepolia } from 'viem/chains';
import { ERC20ContractClient } from '../src';
import { mainnet, sepolia } from 'viem/chains';
import { ERC20ContractClient, ERC721ContractClient } from '../src';

describe('contracts tests', () => {
test('erc20 tests', async () => {
Expand All @@ -26,4 +26,27 @@ describe('contracts tests', () => {

expect(metadata).toStrictEqual({ name: 'USDC', symbol: 'USDC', totalSupply: 411951814911779444n, decimals: 6 });
});

test('erc721 tests', async () => {
//https://opensea.io/collection/seeing-signs
const erc721Contract = new ERC721ContractClient({
chain: mainnet,
contractAddress: '0xbc37ee54f066e79c23389c55925f877f79f3cb84',
});

const metadata = await erc721Contract.tokenMetadata();

const [name, symbol, totalSupply] = await Promise.all([
erc721Contract.name(),
erc721Contract.symbol(),
erc721Contract.totalSupply(),
]);

expect(metadata.name).toBe(name);
expect(metadata.symbol).toBe(symbol);
expect(metadata.totalSupply).toBe(totalSupply);

const balance = await erc721Contract.balanceOf('0x303dD5e268855d6A04f3E44f33201180EDee7aFe');
expect(balance).toBe(0n);
});
});
3 changes: 2 additions & 1 deletion packages/svm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"@metaplex-foundation/umi-bundle-defaults": "^1.2.0",
"@noble/ed25519": "^2.3.0",
"@solana/spl-token-metadata": "^0.1.6",
"bs58": "^6.0.0"
"bs58": "^6.0.0",
"@coral-xyz/anchor": "^0.30.1"
},
"devDependencies": {
"@solana/spl-token": "^0.4.12",
Expand Down
126 changes: 126 additions & 0 deletions packages/svm/src/contracts/ContractClientBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Program, AnchorProvider, setProvider, Wallet } from '@coral-xyz/anchor';
import type { Idl } from '@coral-xyz/anchor';
import type {
AllAccountsMap,
AllInstructions,
IdlAccounts,
MakeMethodsNamespace,
} from '@coral-xyz/anchor/dist/cjs/program/namespace/types';
import { clusterApiUrl, Connection, PublicKey } from '@solana/web3.js';
import type { Cluster, Commitment, Signer } from '@solana/web3.js';

export interface ContractClientBaseOptions {
chainId?: string;
idl?: any;
walletClient: Wallet;
endpoint: string;
}

export abstract class ContractClientBase<T extends Idl = Idl> {
protected program: Program<T>;
protected connection: Connection;
protected wallet: Wallet;
protected chainId: string;
endpoint: string;

constructor(contractInfo: ContractClientBaseOptions) {
const { idl, walletClient, chainId, endpoint } = contractInfo;
this.chainId = chainId || 'mainnet-beta';

this.connection = this.getConnection();
this.wallet = walletClient;
this.endpoint = endpoint;

const provider = new AnchorProvider(this.connection, this.wallet, { commitment: 'confirmed' });
setProvider(provider);

this.program = new Program(idl, provider);
}

get programId() {
return this.program.programId;
}

protected getConnection() {
const rpcUrl = this.endpoint ? this.endpoint : clusterApiUrl((this.chainId as Cluster) ?? 'mainnet-beta');

const conn = new Connection(rpcUrl);
return conn;
}

protected async findProgramAddress(
seeds: Array<Buffer | Uint8Array>,
programId?: PublicKey,
): Promise<[PublicKey, number]> {
return await PublicKey.findProgramAddressSync(seeds, programId || new PublicKey(this.program.idl.address));
}

async fetchAccountData<A extends keyof AllAccountsMap<T>>(
accountName: A,
address: PublicKey | string,
commitment?: Commitment,
): Promise<IdlAccounts<T>[A]> {
try {
if (!this.program.account[accountName]) {
throw new Error(`Account ${String(accountName)} not found in program`);
}

const account = await this.program.account[accountName].fetch(address, commitment);
return account as IdlAccounts<T>[A];
} catch (error) {
throw error;
}
}

async invokeView<M extends keyof MakeMethodsNamespace<T, AllInstructions<T>>>(
methodName: M,
args: Parameters<MakeMethodsNamespace<T, AllInstructions<T>>[M]>,
): Promise<any> {
try {
if (!this.program.methods[methodName]) {
throw new Error(`Method ${methodName} not found in program`);
}

const result = await this.program.methods[methodName](...args).view();

return result;
} catch (error) {
throw error;
}
}

async invokeInstruction<M extends keyof MakeMethodsNamespace<T, AllInstructions<T>>>(
methodName: M,
accounts: any,
args: any,
options?: { signers?: Signer[] },
): Promise<string> {
if (!this.program.methods[methodName]) {
throw new Error(`Method ${methodName} not found in program`);
}
let instruction = this.program.methods[methodName](...args).accounts(accounts);

if (options?.signers?.length) {
instruction = instruction.signers(options.signers);
}

const tx = await instruction.rpc();

await this.confirmTransaction(tx);

return tx;
}

async confirmTransaction(tx: string): Promise<string> {
const latestBlockhash = await this.connection.getLatestBlockhash();
await this.connection.confirmTransaction(
{
signature: tx,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
},
'finalized',
);
return tx;
}
}
Loading