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 Solana support to ZetaChain client and browser deposits #199

Merged
merged 3 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@nomiclabs/hardhat-ethers": "^2.2.3",
"@openzeppelin/contracts": "^5.0.2",
"@openzeppelin/contracts-upgradeable": "^5.0.2",
"@solana/wallet-adapter-react": "^0.15.35",
"@solana/web3.js": "^1.95.3",
"@uniswap/v2-periphery": "^1.1.0-beta.0",
"@zetachain/faucet-cli": "^4.1.1",
Expand Down
52 changes: 49 additions & 3 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { Wallet as SolanaWallet } from "@coral-xyz/anchor";
import type { WalletContextState } from "@solana/wallet-adapter-react";
import { PublicKey } from "@solana/web3.js";
import { networks } from "@zetachain/networks";
import type { Signer, Wallet } from "ethers";
import merge from "lodash/merge";
Expand Down Expand Up @@ -35,16 +38,45 @@ export interface ZetaChainClientParamsBase {

export type ZetaChainClientParams = ZetaChainClientParamsBase &
(
| { signer: Signer; wallet?: never }
| { signer?: never; wallet: Wallet }
| { signer?: undefined; wallet?: undefined }
| {
signer: Signer;
solanaAdapter?: never;
solanaWallet?: never;
wallet?: never;
}
| {
signer?: never;
solanaAdapter: WalletContextState;
solanaWallet?: never;
wallet?: never;
}
| {
signer?: never;
solanaAdapter?: never;
solanaWallet: SolanaWallet;
wallet?: never;
}
| {
signer?: never;
solanaAdapter?: never;
solanaWallet?: never;
wallet: Wallet;
}
| {
signer?: undefined;
solanaAdapter?: undefined;
solanaWallet?: undefined;
wallet?: undefined;
}
);

export class ZetaChainClient {
public chains: { [key: string]: any };
public network: string;
public wallet: Wallet | undefined;
public signer: any | undefined;
public solanaWallet: SolanaWallet | undefined;
public solanaAdapter: WalletContextState | undefined;

/**
* Initializes ZetaChainClient instance.
Expand Down Expand Up @@ -98,6 +130,10 @@ export class ZetaChainClient {
this.wallet = params.wallet;
} else if (params.signer) {
this.signer = params.signer;
} else if (params.solanaWallet) {
this.solanaWallet = params.solanaWallet;
} else if (params.solanaAdapter) {
this.solanaAdapter = params.solanaAdapter;
Comment on lines +131 to +134
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add checks for multiple wallet parameters in the constructor

Currently, the constructor checks for both wallet and signer being provided but doesn't handle cases where multiple Solana wallet parameters are supplied together. To prevent conflicting wallet parameters, ensure that only one wallet type is provided at a time.

You can update the constructor to include these checks:

 if (params.wallet && params.signer) {
   throw new Error("You can only provide a wallet or a signer, not both.");
+} else if (
+  [params.wallet, params.signer, params.solanaWallet, params.solanaAdapter].filter(Boolean).length > 1
+) {
+  throw new Error("You can only provide one wallet parameter.");
 } else if (params.wallet) {
   this.wallet = params.wallet;
 } else if (params.signer) {
   this.signer = params.signer;
 } else if (params.solanaWallet) {
   this.solanaWallet = params.solanaWallet;
 } else if (params.solanaAdapter) {
   this.solanaAdapter = params.solanaAdapter;
 }

This ensures that if more than one wallet parameter is provided, an error is thrown.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (params.solanaWallet) {
this.solanaWallet = params.solanaWallet;
} else if (params.solanaAdapter) {
this.solanaAdapter = params.solanaAdapter;
if (params.wallet && params.signer) {
throw new Error("You can only provide a wallet or a signer, not both.");
} else if (
[params.wallet, params.signer, params.solanaWallet, params.solanaAdapter].filter(Boolean).length > 1
) {
throw new Error("You can only provide one wallet parameter.");
} else if (params.wallet) {
this.wallet = params.wallet;
} else if (params.signer) {
this.signer = params.signer;
} else if (params.solanaWallet) {
this.solanaWallet = params.solanaWallet;
} else if (params.solanaAdapter) {
this.solanaAdapter = params.solanaAdapter;

}
this.chains = { ...networks };
this.network = params.network || "";
Expand All @@ -117,6 +153,16 @@ export class ZetaChainClient {
return this.chains;
}

public isSolanaWalletConnected(): boolean {
return this.solanaAdapter?.connected || this.solanaWallet !== undefined;
}
Comment on lines +154 to +156
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Ensure consistent return type in isSolanaWalletConnected

The method currently returns undefined or boolean due to the use of optional chaining. This can lead to unexpected behavior when the result is used in conditional statements.

Modify the method to ensure it always returns a boolean:

 public isSolanaWalletConnected(): boolean {
-  return this.solanaAdapter?.connected || this.solanaWallet !== undefined;
+  return (
+    (this.solanaAdapter ? this.solanaAdapter.connected : false) ||
+    (this.solanaWallet !== undefined)
+  );
 }

This change guarantees that the method returns true or false, avoiding potential issues with undefined.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public isSolanaWalletConnected(): boolean {
return this.solanaAdapter?.connected || this.solanaWallet !== undefined;
}
public isSolanaWalletConnected(): boolean {
return (
(this.solanaAdapter ? this.solanaAdapter.connected : false) ||
(this.solanaWallet !== undefined)
);
}


public getSolanaPublicKey(): PublicKey | null {
return (
this.solanaAdapter?.publicKey || this.solanaWallet?.publicKey || null
);
}

getEndpoint = getEndpoint;
getBalances = getBalances;
getForeignCoins = getForeignCoins;
Expand Down
114 changes: 68 additions & 46 deletions packages/client/src/solanaDeposit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as anchor from "@coral-xyz/anchor";
import { Keypair } from "@solana/web3.js";
import { TransactionMessage, VersionedTransaction } from "@solana/web3.js";
import { Transaction } from "@solana/web3.js";
import { ethers } from "ethers";

import { ZetaChainClient } from "./client";
Expand All @@ -12,20 +13,50 @@ export const solanaDeposit = async function (
args: {
amount: number;
api: string;
idPath: string;
params: any[];
recipient: string;
}
) {
const keypair = await getKeypairFromFile(args.idPath);
const wallet = new anchor.Wallet(keypair);
if (!this.isSolanaWalletConnected()) {
throw new Error("Solana wallet not connected");
}

const connection = new anchor.web3.Connection(args.api);
const provider = new anchor.AnchorProvider(
connection,
wallet,
anchor.AnchorProvider.defaultOptions()
);

let provider;
if (this.solanaAdapter) {
const walletAdapter = {
publicKey: this.solanaAdapter.publicKey!,
signAllTransactions: async (txs: Transaction[]) => {
if (!this.solanaAdapter?.signAllTransactions) {
throw new Error(
"Wallet does not support signing multiple transactions"
);
}
return await this.solanaAdapter.signAllTransactions(txs);
},
signTransaction: async (tx: Transaction) => {
if (!this.solanaAdapter?.signTransaction) {
throw new Error("Wallet does not support transaction signing");
}
return await this.solanaAdapter.signTransaction(tx);
},
};

provider = new anchor.AnchorProvider(
connection,
walletAdapter as any,
anchor.AnchorProvider.defaultOptions()
Comment on lines +49 to +52
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid using the any type when casting walletAdapter.

Casting walletAdapter to any defeats TypeScript's type safety. It's better to define a proper interface for walletAdapter to ensure type safety and catch potential errors at compile time.

You can define an interface and use it instead of any:

+ interface WalletAdapter {
+   publicKey: anchor.web3.PublicKey;
+   signAllTransactions: (txs: Transaction[]) => Promise<Transaction[]>;
+   signTransaction: (tx: Transaction) => Promise<Transaction>;
+ }
  
  provider = new anchor.AnchorProvider(
    connection,
-   walletAdapter as any,
+   walletAdapter as WalletAdapter,
    anchor.AnchorProvider.defaultOptions()
  );

Committable suggestion skipped: line range outside the PR's diff.

);
} else if (this.solanaWallet) {
provider = new anchor.AnchorProvider(
connection,
this.solanaWallet,
anchor.AnchorProvider.defaultOptions()
);
} else {
throw new Error("No valid Solana wallet found");
}
anchor.setProvider(provider);

const programId = new anchor.web3.PublicKey(Gateway_IDL.address);
Expand Down Expand Up @@ -58,53 +89,44 @@ export const solanaDeposit = async function (
.deposit(depositAmount, m)
.accounts({
pda: pdaAccount,
signer: wallet.publicKey,
signer: this.solanaAdapter
? this.solanaAdapter.publicKey!
: this.solanaWallet!.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.instruction();

tx.add(depositInstruction);

// Send the transaction
const txSignature = await anchor.web3.sendAndConfirmTransaction(
connection,
tx,
[keypair]
);
let txSignature;
if (this.solanaAdapter) {
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
const messageLegacy = new TransactionMessage({
instructions: tx.instructions,
payerKey: this.solanaAdapter.publicKey!,
recentBlockhash: blockhash,
}).compileToV0Message();

console.log("Transaction signature:", txSignature);
} catch (error) {
console.error("Transaction failed:", error);
}
};
const versionedTransaction = new VersionedTransaction(messageLegacy);

const getKeypairFromFile = async (filepath: string) => {
const path = await import("path");
if (filepath[0] === "~") {
const home = process.env.HOME || null;
if (home) {
filepath = path.join(home, filepath.slice(1));
txSignature = await this.solanaAdapter.sendTransaction(
versionedTransaction,
connection
);
} else {
Comment on lines +106 to +121
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Utilize lastValidBlockHeight when sending the transaction.

You retrieve lastValidBlockHeight but do not use it. Including it in the send options ensures that the transaction is valid within the expected block height range and can prevent transactions from failing due to blockhash expiration.

Apply this change to include lastValidBlockHeight:

  txSignature = await this.solanaAdapter.sendTransaction(
    versionedTransaction,
    connection,
+   {
+     minContextSlot: lastValidBlockHeight,
+   }
  );

This ensures that the transaction remains valid and provides additional safeguards against blockhash-related issues.

Committable suggestion skipped: line range outside the PR's diff.

txSignature = await anchor.web3.sendAndConfirmTransaction(
connection,
tx,
[this.solanaWallet!.payer]
);
}
}
// Get contents of file
let fileContents;
try {
const { readFile } = await import("fs/promises");
const fileContentsBuffer = await readFile(filepath);
fileContents = fileContentsBuffer.toString();

console.log("Transaction signature:", txSignature);

return txSignature;
} catch (error) {
throw new Error(`Could not read keypair from file at '${filepath}'`);
}
// Parse contents of file
let parsedFileContents;
try {
parsedFileContents = Uint8Array.from(JSON.parse(fileContents));
} catch (thrownObject) {
const error: any = thrownObject;
if (!error.message.includes("Unexpected token")) {
throw error;
}
throw new Error(`Invalid secret key file at '${filepath}'!`);
console.error("Transaction failed:", error);
}
Comment on lines 132 to 134
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Properly handle errors to prevent unexpected behavior.

Currently, the function logs the error but does not rethrow it or return a value, which could lead to inconsistent behavior for callers expecting a transaction signature or an error.

Modify the catch block to rethrow the error:

  console.error("Transaction failed:", error);
+ throw error;

Alternatively, ensure the function returns a consistent result or handles the error appropriately.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
throw new Error(`Could not read keypair from file at '${filepath}'`);
}
// Parse contents of file
let parsedFileContents;
try {
parsedFileContents = Uint8Array.from(JSON.parse(fileContents));
} catch (thrownObject) {
const error: any = thrownObject;
if (!error.message.includes("Unexpected token")) {
throw error;
}
throw new Error(`Invalid secret key file at '${filepath}'!`);
console.error("Transaction failed:", error);
}
} catch (error) {
console.error("Transaction failed:", error);
throw error;
}

return Keypair.fromSecretKey(parsedFileContents);
};
42 changes: 40 additions & 2 deletions packages/tasks/src/solanaDeposit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Wallet } from "@coral-xyz/anchor";
import { Keypair } from "@solana/web3.js";
import bech32 from "bech32";
import { utils } from "ethers";
import { task } from "hardhat/config";
Expand All @@ -9,7 +11,12 @@ export const solanaDeposit = async (
args: any,
hre: HardhatRuntimeEnvironment
) => {
const client = new ZetaChainClient({ network: "testnet" });
const keypair = await getKeypairFromFile(args.idPath);
const wallet = new Wallet(keypair);

const client = new ZetaChainClient({
solanaWallet: wallet,
});
let recipient;
try {
if ((bech32 as any).decode(args.recipient)) {
Expand All @@ -23,7 +30,7 @@ export const solanaDeposit = async (
}
const { amount, api, idPath } = args;
const params = [JSON.parse(args.types), args.values];
await client.solanaDeposit({ amount, api, idPath, params, recipient });
await client.solanaDeposit({ amount, api, params, recipient });
lukema95 marked this conversation as resolved.
Show resolved Hide resolved
};

task("solana-deposit", "Solana deposit", solanaDeposit)
Expand All @@ -33,3 +40,34 @@ task("solana-deposit", "Solana deposit", solanaDeposit)
.addOptionalParam("idPath", "Path to id.json", "~/.config/solana/id.json")
.addParam("types", "The types of the parameters (example: ['string'])")
.addVariadicPositionalParam("values", "The values of the parameters");

export const getKeypairFromFile = async (filepath: string) => {
const path = await import("path");
if (filepath[0] === "~") {
const home = process.env.HOME || null;
if (home) {
filepath = path.join(home, filepath.slice(1));
Comment on lines +48 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use 'os.homedir()' for cross-platform home directory resolution

Using process.env.HOME may not be reliable across all operating systems (e.g., it may not be defined on Windows). Instead, use os.homedir() from Node.js's os module to get the home directory in a cross-platform manner.

Apply this diff:

+import os from "os";
...
-    const home = process.env.HOME || null;
+    const home = os.homedir();

Committable suggestion skipped: line range outside the PR's diff.

}
}
// Get contents of file
let fileContents;
try {
const { readFile } = await import("fs/promises");
const fileContentsBuffer = await readFile(filepath);
fileContents = fileContentsBuffer.toString();
} catch (error) {
throw new Error(`Could not read keypair from file at '${filepath}'`);
}
// Parse contents of file
let parsedFileContents;
try {
parsedFileContents = Uint8Array.from(JSON.parse(fileContents));
} catch (thrownObject) {
const error: any = thrownObject;
if (!error.message.includes("Unexpected token")) {
throw error;
}
throw new Error(`Invalid secret key file at '${filepath}'!`);
}
return Keypair.fromSecretKey(parsedFileContents);
};
2 changes: 2 additions & 0 deletions typechain-types/@openzeppelin/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
import type * as interfaces from "./interfaces";
export type { interfaces };
import type * as token from "./token";
export type { token };
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
import type { BaseContract, Signer, utils } from "ethers";

import type { Listener, Provider } from "@ethersproject/providers";
import type {
TypedEventFilter,
TypedEvent,
TypedListener,
OnEvent,
} from "../../../../common";

export interface IERC1155ErrorsInterface extends utils.Interface {
functions: {};

events: {};
}

export interface IERC1155Errors extends BaseContract {
connect(signerOrProvider: Signer | Provider | string): this;
attach(addressOrName: string): this;
deployed(): Promise<this>;

interface: IERC1155ErrorsInterface;

queryFilter<TEvent extends TypedEvent>(
event: TypedEventFilter<TEvent>,
fromBlockOrBlockhash?: string | number | undefined,
toBlock?: string | number | undefined
): Promise<Array<TEvent>>;

listeners<TEvent extends TypedEvent>(
eventFilter?: TypedEventFilter<TEvent>
): Array<TypedListener<TEvent>>;
listeners(eventName?: string): Array<Listener>;
removeAllListeners<TEvent extends TypedEvent>(
eventFilter: TypedEventFilter<TEvent>
): this;
removeAllListeners(eventName?: string): this;
off: OnEvent<this>;
on: OnEvent<this>;
once: OnEvent<this>;
removeListener: OnEvent<this>;

functions: {};

callStatic: {};

filters: {};

estimateGas: {};

populateTransaction: {};
}
Loading
Loading