feat(transactions): add support for gasless transactions (#2818)

Co-authored-by: Joaquim Verges <[email protected]>
This commit is contained in:
Jonas Daniels
2024-04-23 21:56:19 -07:00
committed by GitHub
co-authored by Joaquim Verges
parent d7e6671c0a
commit 948f155e7c
18 changed files with 1019 additions and 12 deletions
+36
View File
@@ -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);
```
+3
View File
@@ -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.
+19 -2
View File
@@ -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();
};
+9 -2
View File
@@ -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