Skip to content

Commit 948f155

Browse files
feat(transactions): add support for gasless transactions (#2818)
Co-authored-by: Joaquim Verges <joaquim.verges@gmail.com>
1 parent d7e6671 commit 948f155

File tree

18 files changed

+1019
-12
lines changed

18 files changed

+1019
-12
lines changed

.changeset/grumpy-kangaroos-relax.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
**Gasless transactions in Typescript**
6+
7+
```ts
8+
import { sendTransaction } from "thirdweb";
9+
10+
const result = sendTransaction({
11+
transaction,
12+
account,
13+
gasless: {
14+
provider: "engine",
15+
relayerUrl: "https://...",
16+
relayerForwarderAddress: "0x...",
17+
},
18+
});
19+
```
20+
21+
**Gasless transactions in React**
22+
23+
```jsx
24+
import { useSendTransaction } from "thirdweb/react";
25+
26+
const { mutate } = useSendTransaction({
27+
gasless: {
28+
provider: "engine",
29+
relayerUrl: "https://...",
30+
relayerForwarderAddress: "0x...",
31+
},
32+
});
33+
34+
// Call mutate with the transaction object
35+
mutate(transaction);
36+
```

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@
88
"typescript.preferences.importModuleSpecifier": "relative",
99
"[typescriptreact]": {
1010
"editor.defaultFormatter": "biomejs.biome"
11+
},
12+
"[markdown]": {
13+
"editor.defaultFormatter": "esbenp.prettier-vscode"
1114
}
1215
}

packages/thirdweb/src/react/core/hooks/contract/useSendTransaction.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
22
import { estimateGasCost } from "../../../../transaction/actions/estimate-gas-cost.js";
3+
import type { GaslessOptions } from "../../../../transaction/actions/gasless/types.js";
34
import { sendTransaction } from "../../../../transaction/actions/send-transaction.js";
45
import type { WaitForReceiptOptions } from "../../../../transaction/actions/wait-for-tx-receipt.js";
56
import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
@@ -34,6 +35,7 @@ type ShowModalData = {
3435
*/
3536
export function useSendTransactionCore(
3637
showPayModal?: (data: ShowModalData) => void,
38+
gasless?: GaslessOptions,
3739
): UseMutationResult<WaitForReceiptOptions, Error, PreparedTransaction> {
3840
const account = useActiveAccount();
3941

@@ -47,6 +49,7 @@ export function useSendTransactionCore(
4749
return sendTransaction({
4850
transaction: tx,
4951
account,
52+
gasless,
5053
});
5154
}
5255

@@ -56,6 +59,7 @@ export function useSendTransactionCore(
5659
const res = await sendTransaction({
5760
transaction: tx,
5861
account,
62+
gasless,
5963
});
6064

6165
resolve(res);

packages/thirdweb/src/react/core/hooks/wallets/wallet-hooks.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,14 @@ export function useConnect() {
171171
* Disconnect from given account
172172
* @example
173173
* ```jsx
174-
* import { useDisconnect } from "thirdweb/react";
174+
* import { useDisconnect, useActiveWallet } from "thirdweb/react";
175175
*
176176
* function Example() {
177177
* const { disconnect } = useDisconnect();
178+
* const wallet = useActiveWallet();
178179
*
179180
* return (
180-
* <button onClick={() => disconnect(account)}>
181+
* <button onClick={() => disconnect(wallet)}>
181182
* Disconnect
182183
* </button>
183184
* );

packages/thirdweb/src/react/web/hooks/useSendTransaction.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useContext, useState } from "react";
22
import type { ThirdwebClient } from "../../../client/client.js";
3+
import type { GaslessOptions } from "../../../transaction/actions/gasless/types.js";
34
import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js";
45
import { useSendTransactionCore } from "../../core/hooks/contract/useSendTransaction.js";
56
import { SetRootElementContext } from "../../core/providers/RootElementContext.js";
@@ -47,6 +48,12 @@ export type SendTransactionConfig = {
4748
theme?: Theme | "light" | "dark";
4849
}
4950
| false;
51+
52+
/**
53+
* Configuration for gasless transactions.
54+
* Refer to [`GaslessOptions`](https://portal.thirdweb.com/references/typescript/v5/GaslessOptions) for more details.
55+
*/
56+
gasless?: GaslessOptions;
5057
};
5158

5259
/**
@@ -91,6 +98,7 @@ export function useSendTransaction(config: SendTransactionConfig = {}) {
9198
/>,
9299
);
93100
},
101+
config.gasless,
94102
);
95103
}
96104

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { beforeAll, describe, expect, it } from "vitest";
2+
import { FORKED_ETHEREUM_CHAIN_WITH_MINING } from "../../../../../test/src/chains.js";
3+
import { TEST_CLIENT } from "../../../../../test/src/test-clients.js";
4+
import {
5+
TEST_ACCOUNT_A,
6+
TEST_ACCOUNT_B,
7+
} from "../../../../../test/src/test-wallets.js";
8+
import {
9+
type ThirdwebContract,
10+
getContract,
11+
} from "../../../../contract/contract.js";
12+
import { mintTo } from "../../../../extensions/erc20/write/mintTo.js";
13+
import { transfer } from "../../../../extensions/erc20/write/transfer.js";
14+
import { deployERC20Contract } from "../../../../extensions/prebuilts/deploy-erc20.js";
15+
import { isHex } from "../../../../utils/encoding/hex.js";
16+
import { sendAndConfirmTransaction } from "../../send-and-confirm-transaction.js";
17+
import { toSerializableTransaction } from "../../to-serializable-transaction.js";
18+
import { prepareBiconomyTransaction } from "./biconomy.js";
19+
20+
describe.runIf(process.env.TW_SECRET_KEY)("prepareBiconomyTransaction", () => {
21+
let erc20Contract: ThirdwebContract;
22+
beforeAll(async () => {
23+
erc20Contract = getContract({
24+
address: await deployERC20Contract({
25+
account: TEST_ACCOUNT_A,
26+
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
27+
client: TEST_CLIENT,
28+
params: { name: "Test Token", symbol: "TST" },
29+
type: "TokenERC20",
30+
}),
31+
chain: FORKED_ETHEREUM_CHAIN_WITH_MINING,
32+
client: TEST_CLIENT,
33+
});
34+
// mint some tokens to the account
35+
await sendAndConfirmTransaction({
36+
transaction: mintTo({
37+
contract: erc20Contract,
38+
amount: "1000",
39+
to: TEST_ACCOUNT_A.address,
40+
}),
41+
account: TEST_ACCOUNT_A,
42+
});
43+
}, 60_000);
44+
it("should prepare a Biconomy transaction", async () => {
45+
const transaction = transfer({
46+
amount: "100",
47+
contract: erc20Contract,
48+
to: TEST_ACCOUNT_B.address,
49+
});
50+
51+
// seralizable transaction
52+
const serializableTransaction = await toSerializableTransaction({
53+
transaction,
54+
from: TEST_ACCOUNT_A.address,
55+
});
56+
57+
const result = await prepareBiconomyTransaction({
58+
account: TEST_ACCOUNT_A,
59+
serializableTransaction,
60+
transaction,
61+
gasless: {
62+
apiId: "TEST_ID",
63+
apiKey: "TEST_KEY",
64+
// mainnet forwarder address
65+
relayerForwarderAddress: "0x84a0856b038eaAd1cC7E297cF34A7e72685A8693",
66+
provider: "biconomy",
67+
},
68+
});
69+
70+
const [request, signature] = result;
71+
72+
expect(isHex(signature)).toBe(true);
73+
expect(request.batchId).toBe(0n);
74+
expect(request.batchNonce).toBe(0n);
75+
expect(request.data).toMatchInlineSnapshot(
76+
`"0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000056bc75e2d63100000"`,
77+
);
78+
expect(request.deadline).toBeGreaterThan(0);
79+
expect(request.from).toBe(TEST_ACCOUNT_A.address);
80+
expect(request.to).toBe(erc20Contract.address);
81+
});
82+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { Address } from "abitype";
2+
import { type TransactionSerializable, encodeAbiParameters } from "viem";
3+
import { ADDRESS_ZERO } from "../../../../constants/addresses.js";
4+
import { getContract } from "../../../../contract/contract.js";
5+
import { isHex } from "../../../../utils/encoding/helpers/is-hex.js";
6+
import { keccak256 } from "../../../../utils/hashing/keccak256.js";
7+
import { stringify } from "../../../../utils/json.js";
8+
import type { Account } from "../../../../wallets/interfaces/wallet.js";
9+
import type { PreparedTransaction } from "../../../prepare-transaction.js";
10+
import { readContract } from "../../../read-contract.js";
11+
import type { WaitForReceiptOptions } from "../../wait-for-tx-receipt.js";
12+
13+
export type BiconomyOptions = {
14+
provider: "biconomy";
15+
// you can find the correct forwarder for your network here: https://docs-gasless.biconomy.io/misc/contract-addresses
16+
relayerForwarderAddress: Address;
17+
apiId: string;
18+
apiKey: string;
19+
deadlineSeconds?: number; // default: 3600
20+
};
21+
22+
type SendBiconomyTransactionOptions = {
23+
account: Account;
24+
// TODO: update this to `Transaction<"prepared">` once the type is available to ensure only prepared transactions are accepted
25+
// biome-ignore lint/suspicious/noExplicitAny: library function that accepts any prepared transaction type
26+
transaction: PreparedTransaction<any>;
27+
serializableTransaction: TransactionSerializable;
28+
gasless: BiconomyOptions;
29+
};
30+
31+
// we do not send multiple batches so this stays consistent
32+
const BATCH_ID = 0n;
33+
34+
/**
35+
* @internal - only exported for testing
36+
*/
37+
export async function prepareBiconomyTransaction({
38+
account,
39+
serializableTransaction,
40+
transaction,
41+
gasless,
42+
}: SendBiconomyTransactionOptions) {
43+
const forwarderContract = getContract({
44+
address: gasless.relayerForwarderAddress,
45+
chain: transaction.chain,
46+
client: transaction.client,
47+
});
48+
49+
// get the nonce
50+
const nonce = await readContract({
51+
contract: forwarderContract,
52+
method: "function getNonce(address,uint256) view returns (uint256)",
53+
params: [account.address, BATCH_ID],
54+
});
55+
56+
const deadline =
57+
Math.floor(Date.now() / 1000) + (gasless.deadlineSeconds ?? 3600);
58+
59+
const request = {
60+
from: account.address,
61+
to: serializableTransaction.to,
62+
token: ADDRESS_ZERO,
63+
txGas: serializableTransaction.gas,
64+
tokenGasPrice: 0n,
65+
batchId: BATCH_ID,
66+
batchNonce: nonce,
67+
deadline: deadline,
68+
data: serializableTransaction.data,
69+
};
70+
71+
if (!request.to) {
72+
throw new Error("Cannot send a transaction without a `to` address");
73+
}
74+
if (!request.txGas) {
75+
throw new Error("Cannot send a transaction without a `gas` value");
76+
}
77+
if (!request.data) {
78+
throw new Error("Cannot send a transaction without a `data` value");
79+
}
80+
81+
// create the hash
82+
const message = encodeAbiParameters(
83+
[
84+
{ type: "address" },
85+
{ type: "address" },
86+
{ type: "address" },
87+
{ type: "uint256" },
88+
{ type: "uint256" },
89+
{ type: "uint256" },
90+
{ type: "uint256" },
91+
{ type: "bytes32" },
92+
],
93+
[
94+
request.from,
95+
request.to,
96+
request.token,
97+
request.txGas,
98+
request.tokenGasPrice,
99+
request.batchId,
100+
request.batchNonce,
101+
keccak256(request.data),
102+
],
103+
);
104+
105+
const signature = await account.signMessage({ message });
106+
107+
return [request, signature] as const;
108+
}
109+
110+
/**
111+
* @internal
112+
*/
113+
export async function relayBiconomyTransaction(
114+
options: SendBiconomyTransactionOptions,
115+
): Promise<WaitForReceiptOptions> {
116+
const [request, signature] = await prepareBiconomyTransaction(options);
117+
118+
// send the transaction to the biconomy api
119+
const response = await fetch(
120+
"https://api.biconomy.io/api/v2/meta-tx/native",
121+
{
122+
method: "POST",
123+
body: stringify({
124+
apiId: options.gasless.apiId,
125+
params: [request, signature],
126+
from: request.from,
127+
to: request.to,
128+
gasLimit: request.txGas,
129+
}),
130+
headers: {
131+
"x-api-key": options.gasless.apiKey,
132+
"Content-Type": "application/json;charset=utf-8",
133+
},
134+
},
135+
);
136+
if (!response.ok) {
137+
response.body?.cancel();
138+
throw new Error(`Failed to send transaction: ${await response.text()}`);
139+
}
140+
const json = await response.json();
141+
const transactionHash = json.txHash;
142+
if (isHex(transactionHash)) {
143+
return {
144+
transactionHash: transactionHash,
145+
chain: options.transaction.chain,
146+
client: options.transaction.client,
147+
};
148+
}
149+
throw new Error(`Failed to send transaction: ${stringify(json)}`);
150+
}

0 commit comments

Comments
 (0)