feat(transactions): add support for gasless transactions (#2818)
Co-authored-by: Joaquim Verges <[email protected]>
This commit is contained in:
co-authored by
Joaquim Verges
parent
d7e6671c0a
commit
948f155e7c
@@ -0,0 +1,36 @@
|
||||
---
|
||||
"thirdweb": minor
|
||||
---
|
||||
|
||||
**Gasless transactions in Typescript**
|
||||
|
||||
```ts
|
||||
import { sendTransaction } from "thirdweb";
|
||||
|
||||
const result = sendTransaction({
|
||||
transaction,
|
||||
account,
|
||||
gasless: {
|
||||
provider: "engine",
|
||||
relayerUrl: "https://...",
|
||||
relayerForwarderAddress: "0x...",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Gasless transactions in React**
|
||||
|
||||
```jsx
|
||||
import { useSendTransaction } from "thirdweb/react";
|
||||
|
||||
const { mutate } = useSendTransaction({
|
||||
gasless: {
|
||||
provider: "engine",
|
||||
relayerUrl: "https://...",
|
||||
relayerForwarderAddress: "0x...",
|
||||
},
|
||||
});
|
||||
|
||||
// Call mutate with the transaction object
|
||||
mutate(transaction);
|
||||
```
|
||||
Vendored
+3
@@ -8,5 +8,8 @@
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
|
||||
import { estimateGasCost } from "../../../../transaction/actions/estimate-gas-cost.js";
|
||||
import type { GaslessOptions } from "../../../../transaction/actions/gasless/types.js";
|
||||
import { sendTransaction } from "../../../../transaction/actions/send-transaction.js";
|
||||
import type { WaitForReceiptOptions } from "../../../../transaction/actions/wait-for-tx-receipt.js";
|
||||
import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
|
||||
@@ -34,6 +35,7 @@ type ShowModalData = {
|
||||
*/
|
||||
export function useSendTransactionCore(
|
||||
showPayModal?: (data: ShowModalData) => void,
|
||||
gasless?: GaslessOptions,
|
||||
): UseMutationResult<WaitForReceiptOptions, Error, PreparedTransaction> {
|
||||
const account = useActiveAccount();
|
||||
|
||||
@@ -47,6 +49,7 @@ export function useSendTransactionCore(
|
||||
return sendTransaction({
|
||||
transaction: tx,
|
||||
account,
|
||||
gasless,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ export function useSendTransactionCore(
|
||||
const res = await sendTransaction({
|
||||
transaction: tx,
|
||||
account,
|
||||
gasless,
|
||||
});
|
||||
|
||||
resolve(res);
|
||||
|
||||
@@ -171,13 +171,14 @@ export function useConnect() {
|
||||
* Disconnect from given account
|
||||
* @example
|
||||
* ```jsx
|
||||
* import { useDisconnect } from "thirdweb/react";
|
||||
* import { useDisconnect, useActiveWallet } from "thirdweb/react";
|
||||
*
|
||||
* function Example() {
|
||||
* const { disconnect } = useDisconnect();
|
||||
* const wallet = useActiveWallet();
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => disconnect(account)}>
|
||||
* <button onClick={() => disconnect(wallet)}>
|
||||
* Disconnect
|
||||
* </button>
|
||||
* );
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import type { ThirdwebClient } from "../../../client/client.js";
|
||||
import type { GaslessOptions } from "../../../transaction/actions/gasless/types.js";
|
||||
import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js";
|
||||
import { useSendTransactionCore } from "../../core/hooks/contract/useSendTransaction.js";
|
||||
import { SetRootElementContext } from "../../core/providers/RootElementContext.js";
|
||||
@@ -47,6 +48,12 @@ export type SendTransactionConfig = {
|
||||
theme?: Theme | "light" | "dark";
|
||||
}
|
||||
| false;
|
||||
|
||||
/**
|
||||
* Configuration for gasless transactions.
|
||||
* Refer to [`GaslessOptions`](https://portal.thirdweb.com/references/typescript/v5/GaslessOptions) for more details.
|
||||
*/
|
||||
gasless?: GaslessOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -91,6 +98,7 @@ export function useSendTransaction(config: SendTransactionConfig = {}) {
|
||||
/>,
|
||||
);
|
||||
},
|
||||
config.gasless,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { FORKED_ETHEREUM_CHAIN_WITH_MINING } from "../../../../../test/src/chains.js";
|
||||
import { TEST_CLIENT } from "../../../../../test/src/test-clients.js";
|
||||
import {
|
||||
TEST_ACCOUNT_A,
|
||||
TEST_ACCOUNT_B,
|
||||
} from "../../../../../test/src/test-wallets.js";
|
||||
import {
|
||||
type ThirdwebContract,
|
||||
getContract,
|
||||
} from "../../../../contract/contract.js";
|
||||
import { mintTo } from "../../../../extensions/erc20/write/mintTo.js";
|
||||
import { transfer } from "../../../../extensions/erc20/write/transfer.js";
|
||||
import { deployERC20Contract } from "../../../../extensions/prebuilts/deploy-erc20.js";
|
||||
import { isHex } from "../../../../utils/encoding/hex.js";
|
||||
import { sendAndConfirmTransaction } from "../../send-and-confirm-transaction.js";
|
||||
import { toSerializableTransaction } from "../../to-serializable-transaction.js";
|
||||
import { prepareBiconomyTransaction } from "./biconomy.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("prepareBiconomyTransaction", () => {
|
||||
let erc20Contract: ThirdwebContract;
|
||||
beforeAll(async () => {
|
||||
erc20Contract = getContract({
|
||||
address: await deployERC20Contract({
|
||||
account: TEST_ACCOUNT_A,
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
params: { name: "Test Token", symbol: "TST" },
|
||||
type: "TokenERC20",
|
||||
}),
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
});
|
||||
// mint some tokens to the account
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: mintTo({
|
||||
contract: erc20Contract,
|
||||
amount: "1000",
|
||||
to: TEST_ACCOUNT_A.address,
|
||||
}),
|
||||
account: TEST_ACCOUNT_A,
|
||||
});
|
||||
}, 60_000);
|
||||
it("should prepare a Biconomy transaction", async () => {
|
||||
const transaction = transfer({
|
||||
amount: "100",
|
||||
contract: erc20Contract,
|
||||
to: TEST_ACCOUNT_B.address,
|
||||
});
|
||||
|
||||
// seralizable transaction
|
||||
const serializableTransaction = await toSerializableTransaction({
|
||||
transaction,
|
||||
from: TEST_ACCOUNT_A.address,
|
||||
});
|
||||
|
||||
const result = await prepareBiconomyTransaction({
|
||||
account: TEST_ACCOUNT_A,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless: {
|
||||
apiId: "TEST_ID",
|
||||
apiKey: "TEST_KEY",
|
||||
// mainnet forwarder address
|
||||
relayerForwarderAddress: "0x84a0856b038eaAd1cC7E297cF34A7e72685A8693",
|
||||
provider: "biconomy",
|
||||
},
|
||||
});
|
||||
|
||||
const [request, signature] = result;
|
||||
|
||||
expect(isHex(signature)).toBe(true);
|
||||
expect(request.batchId).toBe(0n);
|
||||
expect(request.batchNonce).toBe(0n);
|
||||
expect(request.data).toMatchInlineSnapshot(
|
||||
`"0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000056bc75e2d63100000"`,
|
||||
);
|
||||
expect(request.deadline).toBeGreaterThan(0);
|
||||
expect(request.from).toBe(TEST_ACCOUNT_A.address);
|
||||
expect(request.to).toBe(erc20Contract.address);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Address } from "abitype";
|
||||
import { type TransactionSerializable, encodeAbiParameters } from "viem";
|
||||
import { ADDRESS_ZERO } from "../../../../constants/addresses.js";
|
||||
import { getContract } from "../../../../contract/contract.js";
|
||||
import { isHex } from "../../../../utils/encoding/helpers/is-hex.js";
|
||||
import { keccak256 } from "../../../../utils/hashing/keccak256.js";
|
||||
import { stringify } from "../../../../utils/json.js";
|
||||
import type { Account } from "../../../../wallets/interfaces/wallet.js";
|
||||
import type { PreparedTransaction } from "../../../prepare-transaction.js";
|
||||
import { readContract } from "../../../read-contract.js";
|
||||
import type { WaitForReceiptOptions } from "../../wait-for-tx-receipt.js";
|
||||
|
||||
export type BiconomyOptions = {
|
||||
provider: "biconomy";
|
||||
// you can find the correct forwarder for your network here: https://docs-gasless.biconomy.io/misc/contract-addresses
|
||||
relayerForwarderAddress: Address;
|
||||
apiId: string;
|
||||
apiKey: string;
|
||||
deadlineSeconds?: number; // default: 3600
|
||||
};
|
||||
|
||||
type SendBiconomyTransactionOptions = {
|
||||
account: Account;
|
||||
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
|
||||
transaction: PreparedTransaction<any>;
|
||||
serializableTransaction: TransactionSerializable;
|
||||
gasless: BiconomyOptions;
|
||||
};
|
||||
|
||||
// we do not send multiple batches so this stays consistent
|
||||
const BATCH_ID = 0n;
|
||||
|
||||
/**
|
||||
* @internal - only exported for testing
|
||||
*/
|
||||
export async function prepareBiconomyTransaction({
|
||||
account,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless,
|
||||
}: SendBiconomyTransactionOptions) {
|
||||
const forwarderContract = getContract({
|
||||
address: gasless.relayerForwarderAddress,
|
||||
chain: transaction.chain,
|
||||
client: transaction.client,
|
||||
});
|
||||
|
||||
// get the nonce
|
||||
const nonce = await readContract({
|
||||
contract: forwarderContract,
|
||||
method: "function getNonce(address,uint256) view returns (uint256)",
|
||||
params: [account.address, BATCH_ID],
|
||||
});
|
||||
|
||||
const deadline =
|
||||
Math.floor(Date.now() / 1000) + (gasless.deadlineSeconds ?? 3600);
|
||||
|
||||
const request = {
|
||||
from: account.address,
|
||||
to: serializableTransaction.to,
|
||||
token: ADDRESS_ZERO,
|
||||
txGas: serializableTransaction.gas,
|
||||
tokenGasPrice: 0n,
|
||||
batchId: BATCH_ID,
|
||||
batchNonce: nonce,
|
||||
deadline: deadline,
|
||||
data: serializableTransaction.data,
|
||||
};
|
||||
|
||||
if (!request.to) {
|
||||
throw new Error("Cannot send a transaction without a `to` address");
|
||||
}
|
||||
if (!request.txGas) {
|
||||
throw new Error("Cannot send a transaction without a `gas` value");
|
||||
}
|
||||
if (!request.data) {
|
||||
throw new Error("Cannot send a transaction without a `data` value");
|
||||
}
|
||||
|
||||
// create the hash
|
||||
const message = encodeAbiParameters(
|
||||
[
|
||||
{ type: "address" },
|
||||
{ type: "address" },
|
||||
{ type: "address" },
|
||||
{ type: "uint256" },
|
||||
{ type: "uint256" },
|
||||
{ type: "uint256" },
|
||||
{ type: "uint256" },
|
||||
{ type: "bytes32" },
|
||||
],
|
||||
[
|
||||
request.from,
|
||||
request.to,
|
||||
request.token,
|
||||
request.txGas,
|
||||
request.tokenGasPrice,
|
||||
request.batchId,
|
||||
request.batchNonce,
|
||||
keccak256(request.data),
|
||||
],
|
||||
);
|
||||
|
||||
const signature = await account.signMessage({ message });
|
||||
|
||||
return [request, signature] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export async function relayBiconomyTransaction(
|
||||
options: SendBiconomyTransactionOptions,
|
||||
): Promise<WaitForReceiptOptions> {
|
||||
const [request, signature] = await prepareBiconomyTransaction(options);
|
||||
|
||||
// send the transaction to the biconomy api
|
||||
const response = await fetch(
|
||||
"https://api.biconomy.io/api/v2/meta-tx/native",
|
||||
{
|
||||
method: "POST",
|
||||
body: stringify({
|
||||
apiId: options.gasless.apiId,
|
||||
params: [request, signature],
|
||||
from: request.from,
|
||||
to: request.to,
|
||||
gasLimit: request.txGas,
|
||||
}),
|
||||
headers: {
|
||||
"x-api-key": options.gasless.apiKey,
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
response.body?.cancel();
|
||||
throw new Error(`Failed to send transaction: ${await response.text()}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
const transactionHash = json.txHash;
|
||||
if (isHex(transactionHash)) {
|
||||
return {
|
||||
transactionHash: transactionHash,
|
||||
chain: options.transaction.chain,
|
||||
client: options.transaction.client,
|
||||
};
|
||||
}
|
||||
throw new Error(`Failed to send transaction: ${stringify(json)}`);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { FORKED_ETHEREUM_CHAIN_WITH_MINING } from "../../../../../test/src/chains.js";
|
||||
import { TEST_CLIENT } from "../../../../../test/src/test-clients.js";
|
||||
import {
|
||||
TEST_ACCOUNT_A,
|
||||
TEST_ACCOUNT_B,
|
||||
} from "../../../../../test/src/test-wallets.js";
|
||||
import {
|
||||
type ThirdwebContract,
|
||||
getContract,
|
||||
} from "../../../../contract/contract.js";
|
||||
import { polygon } from "../../../../exports/chains.js";
|
||||
import { generateAccount } from "../../../../exports/wallets.js";
|
||||
import { mintTo } from "../../../../extensions/erc20/write/mintTo.js";
|
||||
import { transfer } from "../../../../extensions/erc20/write/transfer.js";
|
||||
import { deployERC20Contract } from "../../../../extensions/prebuilts/deploy-erc20.js";
|
||||
import { add } from "../../../../extensions/thirdweb/__generated__/ITWMultichainRegistry/write/add.js";
|
||||
import { isHex } from "../../../../utils/encoding/hex.js";
|
||||
import { sendAndConfirmTransaction } from "../../send-and-confirm-transaction.js";
|
||||
import { sendTransaction } from "../../send-transaction.js";
|
||||
import { toSerializableTransaction } from "../../to-serializable-transaction.js";
|
||||
import { prepareEngineTransaction } from "./engine.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("prepareengineTransaction", () => {
|
||||
let erc20Contract: ThirdwebContract;
|
||||
beforeAll(async () => {
|
||||
erc20Contract = getContract({
|
||||
address: await deployERC20Contract({
|
||||
account: TEST_ACCOUNT_A,
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
params: { name: "Test Token", symbol: "TST" },
|
||||
type: "TokenERC20",
|
||||
}),
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
});
|
||||
// mint some tokens to the account
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: mintTo({
|
||||
contract: erc20Contract,
|
||||
amount: "1000",
|
||||
to: TEST_ACCOUNT_A.address,
|
||||
}),
|
||||
account: TEST_ACCOUNT_A,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("should prepare an engine relay transaction", async () => {
|
||||
const transaction = transfer({
|
||||
amount: "100",
|
||||
contract: erc20Contract,
|
||||
to: TEST_ACCOUNT_B.address,
|
||||
});
|
||||
|
||||
// seralizable transaction
|
||||
const serializableTransaction = await toSerializableTransaction({
|
||||
transaction,
|
||||
from: TEST_ACCOUNT_A.address,
|
||||
});
|
||||
|
||||
const result = await prepareEngineTransaction({
|
||||
account: TEST_ACCOUNT_A,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless: {
|
||||
provider: "engine",
|
||||
relayerUrl: "https://safe-transaction.engine.com/api/v1",
|
||||
// mainnet forwarder address
|
||||
relayerForwarderAddress: "0xc82BbE41f2cF04e3a8efA18F7032BDD7f6d98a81",
|
||||
},
|
||||
});
|
||||
|
||||
const { message, messageType, signature } = result;
|
||||
|
||||
expect(isHex(signature)).toBe(true);
|
||||
expect(message).toMatchInlineSnapshot(`
|
||||
{
|
||||
"data": "0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000056bc75e2d63100000",
|
||||
"from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
"gas": 64338n,
|
||||
"nonce": 0n,
|
||||
"to": "${erc20Contract.address}",
|
||||
"value": 0n,
|
||||
}
|
||||
`);
|
||||
expect(messageType).toMatchInlineSnapshot(`"forward"`);
|
||||
});
|
||||
|
||||
// This is more of an integration test, re-enable to test relaying functionality
|
||||
it.skip("should send a real tx", async () => {
|
||||
const multichainRegistry = getContract({
|
||||
address: "0xcdAD8FA86e18538aC207872E8ff3536501431B73",
|
||||
chain: polygon,
|
||||
client: TEST_CLIENT,
|
||||
});
|
||||
const transaction = add({
|
||||
contract: multichainRegistry,
|
||||
chainId: 1337n,
|
||||
deployer: TEST_ACCOUNT_A.address,
|
||||
deployment: (await generateAccount({ client: TEST_CLIENT })).address,
|
||||
metadataUri: "",
|
||||
});
|
||||
const res = await sendTransaction({
|
||||
account: TEST_ACCOUNT_A,
|
||||
transaction,
|
||||
gasless: {
|
||||
provider: "engine",
|
||||
relayerUrl:
|
||||
"https://checkout.engine.thirdweb.com/relayer/0c2bdd3a-307f-4243-b6e5-5ba495222d2b",
|
||||
relayerForwarderAddress: "0x409d530a6961297ece29121dbee2c917c3398659",
|
||||
experimentalChainlessSupport: true,
|
||||
},
|
||||
});
|
||||
expect(res.transactionHash).toBeDefined();
|
||||
expect(res.transactionHash.length).toBe(66);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Address } from "abitype";
|
||||
import type { TransactionSerializable } from "viem";
|
||||
import { getContract } from "../../../../contract/contract.js";
|
||||
import { stringify } from "../../../../utils/json.js";
|
||||
import type { Account } from "../../../../wallets/interfaces/wallet.js";
|
||||
import type { PreparedTransaction } from "../../../prepare-transaction.js";
|
||||
import { readContract } from "../../../read-contract.js";
|
||||
import {
|
||||
type WaitForReceiptOptions,
|
||||
waitForReceipt,
|
||||
} from "../../wait-for-tx-receipt.js";
|
||||
|
||||
export type EngineOptions = {
|
||||
provider: "engine";
|
||||
relayerUrl: string;
|
||||
relayerForwarderAddress: Address;
|
||||
domainName?: string; // default: "GSNv2 Forwarder"
|
||||
domainVersion?: string; // default: "0.0.1"
|
||||
domainSeparatorVersion?: string; // default: "1"
|
||||
experimentalChainlessSupport?: boolean; // default: false
|
||||
};
|
||||
|
||||
type SendengineTransactionOptions = {
|
||||
account: Account;
|
||||
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
|
||||
transaction: PreparedTransaction<any>;
|
||||
serializableTransaction: TransactionSerializable;
|
||||
gasless: EngineOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal - only exported for testing
|
||||
*/
|
||||
export async function prepareEngineTransaction({
|
||||
account,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless,
|
||||
}: SendengineTransactionOptions) {
|
||||
const forrwaderContract = getContract({
|
||||
address: gasless.relayerForwarderAddress,
|
||||
chain: transaction.chain,
|
||||
client: transaction.client,
|
||||
});
|
||||
|
||||
const nonce = await readContract({
|
||||
contract: forrwaderContract,
|
||||
method: "function getNonce(address) view returns (uint256)",
|
||||
params: [account.address],
|
||||
});
|
||||
|
||||
const [signature, message] = await (async () => {
|
||||
// TODO: handle special case for `approve` -> `permit` transactions
|
||||
|
||||
if (!serializableTransaction.to) {
|
||||
throw new Error("engine transactions must have a 'to' address");
|
||||
}
|
||||
if (!serializableTransaction.gas) {
|
||||
throw new Error("engine transactions must have a 'gas' value");
|
||||
}
|
||||
if (!serializableTransaction.data) {
|
||||
throw new Error("engine transactions must have a 'data' value");
|
||||
}
|
||||
// chainless support!
|
||||
if (gasless.experimentalChainlessSupport) {
|
||||
const message = {
|
||||
from: account.address,
|
||||
to: serializableTransaction.to,
|
||||
value: 0n,
|
||||
gas: serializableTransaction.gas,
|
||||
nonce: nonce,
|
||||
data: serializableTransaction.data,
|
||||
chainid: BigInt(transaction.chain.id),
|
||||
} as const;
|
||||
return [
|
||||
await account.signTypedData({
|
||||
domain: {
|
||||
name: "GSNv2 Forwarder",
|
||||
version: "0.0.1",
|
||||
verifyingContract: forrwaderContract.address,
|
||||
},
|
||||
message,
|
||||
primaryType: "ForwardRequest",
|
||||
types: { ForwardRequest: ChainAwareForwardRequest },
|
||||
}),
|
||||
message,
|
||||
] as const;
|
||||
}
|
||||
// else non-chainless support
|
||||
const message = {
|
||||
from: account.address,
|
||||
to: serializableTransaction.to,
|
||||
value: 0n,
|
||||
gas: serializableTransaction.gas,
|
||||
nonce: nonce,
|
||||
data: serializableTransaction.data,
|
||||
} as const;
|
||||
return [
|
||||
await account.signTypedData({
|
||||
domain: {
|
||||
name: gasless.domainName ?? "GSNv2 Forwarder",
|
||||
version: gasless.domainVersion ?? "0.0.1",
|
||||
chainId: transaction.chain.id,
|
||||
verifyingContract: forrwaderContract.address,
|
||||
},
|
||||
message,
|
||||
primaryType: "ForwardRequest",
|
||||
types: { ForwardRequest },
|
||||
}),
|
||||
message,
|
||||
] as const;
|
||||
})();
|
||||
// TODO: handle special case for `approve` -> `permit`
|
||||
const messageType = "forward";
|
||||
|
||||
return { message, signature, messageType } as const;
|
||||
}
|
||||
|
||||
export const ForwardRequest = [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "gas", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "data", type: "bytes" },
|
||||
] as const;
|
||||
|
||||
export const ChainAwareForwardRequest = [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "gas", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "data", type: "bytes" },
|
||||
{ name: "chainid", type: "uint256" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export async function relayEngineTransaction(
|
||||
options: SendengineTransactionOptions,
|
||||
): Promise<WaitForReceiptOptions> {
|
||||
const { message, messageType, signature } =
|
||||
await prepareEngineTransaction(options);
|
||||
|
||||
const response = await fetch(options.gasless.relayerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: stringify({
|
||||
request: message,
|
||||
type: messageType,
|
||||
signature,
|
||||
forwarderAddress: options.gasless.relayerForwarderAddress,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send transaction: ${await response.text()}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (!json.result) {
|
||||
throw new Error(`Relay transaction failed: ${json.message}`);
|
||||
}
|
||||
const queueId = json.result.queueId;
|
||||
// poll for transactionHash
|
||||
const timeout = 60000;
|
||||
const interval = 1000;
|
||||
const endtime = Date.now() + timeout;
|
||||
while (Date.now() < endtime) {
|
||||
const receipt = await fetchReceipt({ options, queueId });
|
||||
if (receipt) {
|
||||
return {
|
||||
transactionHash: receipt.transactionHash,
|
||||
chain: options.transaction.chain,
|
||||
client: options.transaction.client,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
throw new Error(`Failed to find relayed transaction after ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function fetchReceipt(args: {
|
||||
options: SendengineTransactionOptions;
|
||||
queueId: string;
|
||||
}) {
|
||||
const { options, queueId } = args;
|
||||
const url = options.gasless.relayerUrl.split("/relayer/")[0];
|
||||
const res = await fetch(`${url}/transaction/status/${queueId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const result = resJson.result;
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
switch (result.status) {
|
||||
case "errored":
|
||||
throw new Error(
|
||||
`Transaction errored with reason: ${result.errorMessage}`,
|
||||
);
|
||||
case "cancelled":
|
||||
throw new Error("Transaction execution cancelled.");
|
||||
case "mined": {
|
||||
const receipt = await waitForReceipt({
|
||||
client: options.transaction.client,
|
||||
chain: options.transaction.chain,
|
||||
transactionHash: result.transactionHash,
|
||||
});
|
||||
return receipt;
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { FORKED_ETHEREUM_CHAIN_WITH_MINING } from "../../../../../test/src/chains.js";
|
||||
import { TEST_CLIENT } from "../../../../../test/src/test-clients.js";
|
||||
import {
|
||||
TEST_ACCOUNT_A,
|
||||
TEST_ACCOUNT_B,
|
||||
} from "../../../../../test/src/test-wallets.js";
|
||||
import {
|
||||
type ThirdwebContract,
|
||||
getContract,
|
||||
} from "../../../../contract/contract.js";
|
||||
import { mintTo } from "../../../../extensions/erc20/write/mintTo.js";
|
||||
import { transfer } from "../../../../extensions/erc20/write/transfer.js";
|
||||
import { deployERC20Contract } from "../../../../extensions/prebuilts/deploy-erc20.js";
|
||||
import { isHex } from "../../../../utils/encoding/hex.js";
|
||||
import { sendAndConfirmTransaction } from "../../send-and-confirm-transaction.js";
|
||||
import { toSerializableTransaction } from "../../to-serializable-transaction.js";
|
||||
import { prepareOpenZeppelinTransaction } from "./openzeppelin.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)(
|
||||
"prepareOpenZeppelinTransaction",
|
||||
() => {
|
||||
let erc20Contract: ThirdwebContract;
|
||||
beforeAll(async () => {
|
||||
erc20Contract = getContract({
|
||||
address: await deployERC20Contract({
|
||||
account: TEST_ACCOUNT_A,
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
params: { name: "Test Token", symbol: "TST" },
|
||||
type: "TokenERC20",
|
||||
}),
|
||||
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
|
||||
client: TEST_CLIENT,
|
||||
});
|
||||
// mint some tokens to the account
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: mintTo({
|
||||
contract: erc20Contract,
|
||||
amount: "1000",
|
||||
to: TEST_ACCOUNT_A.address,
|
||||
}),
|
||||
account: TEST_ACCOUNT_A,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("should prepare an Openzeppelin relay transaction", async () => {
|
||||
const transaction = transfer({
|
||||
amount: "100",
|
||||
contract: erc20Contract,
|
||||
to: TEST_ACCOUNT_B.address,
|
||||
});
|
||||
|
||||
// seralizable transaction
|
||||
const serializableTransaction = await toSerializableTransaction({
|
||||
transaction,
|
||||
from: TEST_ACCOUNT_A.address,
|
||||
});
|
||||
|
||||
const result = await prepareOpenZeppelinTransaction({
|
||||
account: TEST_ACCOUNT_A,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless: {
|
||||
provider: "openzeppelin",
|
||||
relayerUrl: "https://safe-transaction.openzeppelin.com/api/v1",
|
||||
// mainnet forwarder address
|
||||
relayerForwarderAddress: "0xc82BbE41f2cF04e3a8efA18F7032BDD7f6d98a81",
|
||||
},
|
||||
});
|
||||
|
||||
const { message, messageType, signature } = result;
|
||||
|
||||
expect(isHex(signature)).toBe(true);
|
||||
expect(message).toMatchInlineSnapshot(`
|
||||
{
|
||||
"data": "0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000056bc75e2d63100000",
|
||||
"from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
"gas": 64338n,
|
||||
"nonce": 0n,
|
||||
"to": "${erc20Contract.address}",
|
||||
"value": 0n,
|
||||
}
|
||||
`);
|
||||
expect(messageType).toMatchInlineSnapshot(`"forward"`);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Address } from "abitype";
|
||||
import type { TransactionSerializable } from "viem";
|
||||
import { getContract } from "../../../../contract/contract.js";
|
||||
import { isHex } from "../../../../utils/encoding/helpers/is-hex.js";
|
||||
import { stringify } from "../../../../utils/json.js";
|
||||
import type { Account } from "../../../../wallets/interfaces/wallet.js";
|
||||
import type { PreparedTransaction } from "../../../prepare-transaction.js";
|
||||
import { readContract } from "../../../read-contract.js";
|
||||
import type { WaitForReceiptOptions } from "../../wait-for-tx-receipt.js";
|
||||
|
||||
export type OpenZeppelinOptions = {
|
||||
provider: "openzeppelin";
|
||||
relayerUrl: string;
|
||||
relayerForwarderAddress: Address;
|
||||
domainName?: string; // default: "GSNv2 Forwarder"
|
||||
domainVersion?: string; // default: "0.0.1"
|
||||
domainSeparatorVersion?: string; // default: "1"
|
||||
experimentalChainlessSupport?: boolean; // default: false
|
||||
};
|
||||
|
||||
type SendOpenZeppelinTransactionOptions = {
|
||||
account: Account;
|
||||
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
|
||||
transaction: PreparedTransaction<any>;
|
||||
serializableTransaction: TransactionSerializable;
|
||||
gasless: OpenZeppelinOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal - only exported for testing
|
||||
*/
|
||||
export async function prepareOpenZeppelinTransaction({
|
||||
account,
|
||||
serializableTransaction,
|
||||
transaction,
|
||||
gasless,
|
||||
}: SendOpenZeppelinTransactionOptions) {
|
||||
const forrwaderContract = getContract({
|
||||
address: gasless.relayerForwarderAddress,
|
||||
chain: transaction.chain,
|
||||
client: transaction.client,
|
||||
});
|
||||
|
||||
const nonce = await readContract({
|
||||
contract: forrwaderContract,
|
||||
method: "function getNonce(address) view returns (uint256)",
|
||||
params: [account.address],
|
||||
});
|
||||
|
||||
const [signature, message] = await (async () => {
|
||||
// TODO: handle special case for `approve` -> `permit` transactions
|
||||
|
||||
if (!serializableTransaction.to) {
|
||||
throw new Error("OpenZeppelin transactions must have a 'to' address");
|
||||
}
|
||||
if (!serializableTransaction.gas) {
|
||||
throw new Error("OpenZeppelin transactions must have a 'gas' value");
|
||||
}
|
||||
if (!serializableTransaction.data) {
|
||||
throw new Error("OpenZeppelin transactions must have a 'data' value");
|
||||
}
|
||||
// chainless support!
|
||||
if (gasless.experimentalChainlessSupport) {
|
||||
const message = {
|
||||
from: account.address,
|
||||
to: serializableTransaction.to,
|
||||
value: 0n,
|
||||
gas: serializableTransaction.gas,
|
||||
nonce: nonce,
|
||||
data: serializableTransaction.data,
|
||||
chainid: BigInt(transaction.chain.id),
|
||||
} as const;
|
||||
return [
|
||||
await account.signTypedData({
|
||||
domain: {
|
||||
name: "GSNv2 Forwarder",
|
||||
version: "0.0.1",
|
||||
verifyingContract: forrwaderContract.address,
|
||||
},
|
||||
message,
|
||||
primaryType: "ForwardRequest",
|
||||
types: { ForwardRequest: ChainAwareForwardRequest },
|
||||
}),
|
||||
message,
|
||||
] as const;
|
||||
}
|
||||
// else non-chainless support
|
||||
const message = {
|
||||
from: account.address,
|
||||
to: serializableTransaction.to,
|
||||
value: 0n,
|
||||
gas: serializableTransaction.gas,
|
||||
nonce: nonce,
|
||||
data: serializableTransaction.data,
|
||||
} as const;
|
||||
return [
|
||||
await account.signTypedData({
|
||||
domain: {
|
||||
name: gasless.domainName ?? "GSNv2 Forwarder",
|
||||
version: gasless.domainVersion ?? "0.0.1",
|
||||
chainId: transaction.chain.id,
|
||||
verifyingContract: forrwaderContract.address,
|
||||
},
|
||||
message,
|
||||
primaryType: "ForwardRequest",
|
||||
types: { ForwardRequest },
|
||||
}),
|
||||
message,
|
||||
] as const;
|
||||
})();
|
||||
// TODO: handle special case for `approve` -> `permit`
|
||||
const messageType = "forward";
|
||||
|
||||
return { message, signature, messageType } as const;
|
||||
}
|
||||
|
||||
export const ForwardRequest = [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "gas", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "data", type: "bytes" },
|
||||
] as const;
|
||||
|
||||
export const ChainAwareForwardRequest = [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "gas", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "data", type: "bytes" },
|
||||
{ name: "chainid", type: "uint256" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export async function relayOpenZeppelinTransaction(
|
||||
options: SendOpenZeppelinTransactionOptions,
|
||||
): Promise<WaitForReceiptOptions> {
|
||||
const { message, messageType, signature } =
|
||||
await prepareOpenZeppelinTransaction(options);
|
||||
|
||||
const response = await fetch(options.gasless.relayerUrl, {
|
||||
method: "POST",
|
||||
body: stringify({
|
||||
request: message,
|
||||
type: messageType,
|
||||
signature,
|
||||
forwarderAddress: options.gasless.relayerForwarderAddress,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
response.body?.cancel();
|
||||
throw new Error(`Failed to send transaction: ${await response.text()}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (!json.result) {
|
||||
throw new Error(`Relay transaction failed: ${json.message}`);
|
||||
}
|
||||
const transactionHash = JSON.parse(json.result).txHash;
|
||||
if (isHex(transactionHash)) {
|
||||
return {
|
||||
transactionHash,
|
||||
chain: options.transaction.chain,
|
||||
client: options.transaction.client,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Failed to send transaction: ${stringify(json)}`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { TransactionSerializable } from "viem";
|
||||
import type { Account } from "../../../wallets/interfaces/wallet.js";
|
||||
import type { PreparedTransaction } from "../../prepare-transaction.js";
|
||||
import type { WaitForReceiptOptions } from "../wait-for-tx-receipt.js";
|
||||
import type { GaslessOptions } from "./types.js";
|
||||
|
||||
export type SendGaslessTransactionOptions = {
|
||||
account: Account;
|
||||
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
|
||||
transaction: PreparedTransaction<any>;
|
||||
serializableTransaction: TransactionSerializable;
|
||||
gasless: GaslessOptions;
|
||||
};
|
||||
|
||||
export async function sendGaslessTransaction({
|
||||
account,
|
||||
transaction,
|
||||
serializableTransaction,
|
||||
gasless,
|
||||
}: SendGaslessTransactionOptions): Promise<WaitForReceiptOptions> {
|
||||
// TODO: handle special case for mutlicall transactions!
|
||||
// Steps:
|
||||
// 1. check if the method is `multicall` by comparing the 4bytes data with the `multicall` selector
|
||||
// 2. split the rest of the data into its "parts"
|
||||
// 3. solidityPack the parts with the part data + the `account.address`
|
||||
// see v4: `core/classes/transactions.ts>Transaction>prepareGasless:L551`
|
||||
|
||||
if (serializableTransaction.value && serializableTransaction.value > 0n) {
|
||||
throw new Error("Gasless transactions cannot have a value");
|
||||
}
|
||||
|
||||
// TODO: multiply gas by 2 for some reason(?) - we do in v4, *should* we?
|
||||
|
||||
// biconomy
|
||||
if (gasless.provider === "biconomy") {
|
||||
const { relayBiconomyTransaction } = await import(
|
||||
"./providers/biconomy.js"
|
||||
);
|
||||
return relayBiconomyTransaction({
|
||||
account,
|
||||
transaction,
|
||||
serializableTransaction,
|
||||
gasless,
|
||||
});
|
||||
}
|
||||
|
||||
// openzeppelin
|
||||
if (gasless.provider === "openzeppelin") {
|
||||
const { relayOpenZeppelinTransaction } = await import(
|
||||
"./providers/openzeppelin.js"
|
||||
);
|
||||
return relayOpenZeppelinTransaction({
|
||||
account,
|
||||
transaction,
|
||||
serializableTransaction,
|
||||
gasless,
|
||||
});
|
||||
}
|
||||
|
||||
if (gasless.provider === "engine") {
|
||||
const { relayEngineTransaction } = await import("./providers/engine.js");
|
||||
return relayEngineTransaction({
|
||||
account,
|
||||
transaction,
|
||||
serializableTransaction,
|
||||
gasless,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Unsupported gasless provider");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { BiconomyOptions } from "./providers/biconomy.js";
|
||||
import type { EngineOptions } from "./providers/engine.js";
|
||||
import type { OpenZeppelinOptions } from "./providers/openzeppelin.js";
|
||||
|
||||
export type GaslessOptions =
|
||||
| EngineOptions
|
||||
| OpenZeppelinOptions
|
||||
| BiconomyOptions;
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { Account } from "../../wallets/interfaces/wallet.js";
|
||||
import type { PreparedTransaction } from "../prepare-transaction.js";
|
||||
import type { WaitForReceiptOptions } from "./wait-for-tx-receipt.js";
|
||||
|
||||
import type { GaslessOptions } from "./gasless/types.js";
|
||||
import { toSerializableTransaction } from "./to-serializable-transaction.js";
|
||||
import type { WaitForReceiptOptions } from "./wait-for-tx-receipt.js";
|
||||
|
||||
export type SendTransactionOptions = {
|
||||
account: Account;
|
||||
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
|
||||
transaction: PreparedTransaction<any>;
|
||||
gasless?: GaslessOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,12 +31,27 @@ export type SendTransactionOptions = {
|
||||
export async function sendTransaction(
|
||||
options: SendTransactionOptions,
|
||||
): Promise<WaitForReceiptOptions> {
|
||||
const { account, transaction } = options;
|
||||
const { account, transaction, gasless } = options;
|
||||
|
||||
const serializableTx = await toSerializableTransaction({
|
||||
const serializableTransaction = await toSerializableTransaction({
|
||||
transaction: transaction,
|
||||
from: account.address,
|
||||
});
|
||||
const result = await account.sendTransaction(serializableTx);
|
||||
|
||||
// branch for gasless transactions
|
||||
if (gasless) {
|
||||
// lazy load the gasless tx function because it's only needed for gasless transactions
|
||||
const { sendGaslessTransaction } = await import(
|
||||
"./gasless/send-gasless-transaction.js"
|
||||
);
|
||||
return sendGaslessTransaction({
|
||||
account,
|
||||
transaction,
|
||||
serializableTransaction,
|
||||
gasless,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await account.sendTransaction(serializableTransaction);
|
||||
return { ...result, chain: transaction.chain, client: transaction.client };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export type GetWalletBalanceResult = {
|
||||
/**
|
||||
* Retrieves the balance of a token or native currency for a given wallet.
|
||||
* @param options - The options for retrieving the token balance.
|
||||
* @param options.account - The account for which to retrieve the balance.
|
||||
* @param options.address - The address for which to retrieve the balance.
|
||||
* @param options.client - The Thirdweb client to use for the request.
|
||||
* @param options.chain - The chain for which to retrieve the balance.
|
||||
* @param options.tokenAddress - (Optional) The address of the token to retrieve the balance for. If not provided, the balance of the native token will be retrieved.
|
||||
|
||||
@@ -26,8 +26,24 @@ export default async function globalSetup() {
|
||||
},
|
||||
});
|
||||
|
||||
const shutdownOptimism = await startProxy({
|
||||
const shutdownMainnetWithMining = await startProxy({
|
||||
port: 8646,
|
||||
options: {
|
||||
chainId: 1,
|
||||
forkUrl: SECRET_KEY
|
||||
? `https://1.rpc.thirdweb.com/${clientId}`
|
||||
: "https://mainnet.gateway.tenderly.co",
|
||||
forkHeader: SECRET_KEY ? { "x-secret-key": SECRET_KEY } : {},
|
||||
forkChainId: 1,
|
||||
forkBlockNumber: FORK_BLOCK_NUMBER,
|
||||
// ey, i'm mining here!
|
||||
noMining: false,
|
||||
startTimeout: 20000,
|
||||
},
|
||||
});
|
||||
|
||||
const shutdownOptimism = await startProxy({
|
||||
port: 8647,
|
||||
options: {
|
||||
chainId: 10,
|
||||
forkUrl: SECRET_KEY
|
||||
@@ -42,11 +58,12 @@ export default async function globalSetup() {
|
||||
});
|
||||
|
||||
const shutdownAnvil = await startProxy({
|
||||
port: 8647,
|
||||
port: 8648,
|
||||
});
|
||||
|
||||
return async () => {
|
||||
await shutdownMainnet();
|
||||
await shutdownMainnetWithMining();
|
||||
await shutdownOptimism();
|
||||
await shutdownAnvil();
|
||||
};
|
||||
|
||||
@@ -6,8 +6,9 @@ import { defineChain } from "../../src/chains/utils.js";
|
||||
export const poolId = Number(process.env.VITEST_POOL_ID ?? 1);
|
||||
|
||||
export const FORKED_ETHEREUM_RPC = `http://127.0.0.1:8645/${poolId}`;
|
||||
export const FORKED_OPTIMISM_RPC = `http://127.0.0.1:8646/${poolId}`;
|
||||
export const ANVIL_RPC = `http://127.0.0.1:8647/${poolId}`;
|
||||
export const FORKED_ETHEREUM_WITH_MINING_RPC = `http://127.0.0.1:8646/${poolId}`;
|
||||
export const FORKED_OPTIMISM_RPC = `http://127.0.0.1:8647/${poolId}`;
|
||||
export const ANVIL_RPC = `http://127.0.0.1:8648/${poolId}`;
|
||||
|
||||
export const FORKED_ETHEREUM_CHAIN = defineChain({
|
||||
...ethereum,
|
||||
@@ -15,6 +16,12 @@ export const FORKED_ETHEREUM_CHAIN = defineChain({
|
||||
rpc: FORKED_ETHEREUM_RPC,
|
||||
});
|
||||
|
||||
export const FORKED_ETHEREUM_CHAIN_WITH_MINING = defineChain({
|
||||
...ethereum,
|
||||
// override the rpc url
|
||||
rpc: FORKED_ETHEREUM_WITH_MINING_RPC,
|
||||
});
|
||||
|
||||
export const FORKED_OPTIMISM_CHAIN = defineChain({
|
||||
...optimism,
|
||||
// override the rpc url
|
||||
|
||||
Reference in New Issue
Block a user