Skip to content

Commit d256799

Browse files
feat: Add engineAccount() for backend transaction handling
1 parent 0fdfb8a commit d256799

File tree

6 files changed

+315
-2
lines changed

6 files changed

+315
-2
lines changed

.changeset/serious-geese-chew.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+
Introducing `engineAccount()` for backend usage
6+
7+
You can now use `engineAccount()` on the backend to create an account that can send transactions via your engine instance.
8+
9+
This lets you use the full catalog of thirdweb SDK functions and extensions on the backend, with the performance, reliability, and monitoring of your engine instance.
10+
11+
```ts
12+
// get your engine url, auth token, and wallet address from your engine instance on the dashboard
13+
const engine = engineAccount({
14+
engineUrl: process.env.ENGINE_URL,
15+
authToken: process.env.ENGINE_AUTH_TOKEN,
16+
walletAddress: process.env.ENGINE_WALLET_ADDRESS,
17+
});
18+
19+
// Now you can use engineAcc to send transactions, deploy contracts, etc.
20+
// For example, you can prepare extension functions:
21+
const tx = await claimTo({
22+
contract: getContract({ client, chain, address: "0x..." }),
23+
to: "0x...",
24+
tokenId: 0n,
25+
quantity: 1n,
26+
});
27+
28+
// And then send the transaction via engine
29+
// this will automatically wait for the transaction to be mined and return the transaction hash
30+
const result = await sendTransaction({
31+
account: engine, // forward the transaction to your engine instance
32+
transaction: tx,
33+
});
34+
35+
console.log(result.transactionHash);
36+
```

packages/thirdweb/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
# Note: Adding new env also requires defining it in vite.config.ts file
33

44
# required
5+
TW_SECRET_KEY=
56
STORYBOOK_CLIENT_ID=
67

78
# optional - for testing using a specific account
8-
STORYBOOK_ACCOUNT_PRIVATE_KEY=
9+
STORYBOOK_ACCOUNT_PRIVATE_KEY=
10+
11+
# optional - for testing using a specific engine
12+
ENGINE_URL=
13+
ENGINE_AUTH_TOKEN=
14+
ENGINE_WALLET_ADDRESS=
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {
2+
type EngineAccountOptions,
3+
engineAccount,
4+
} from "../../wallets/engine/index.js";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
import { TEST_CLIENT } from "../../../test/src/test-clients.js";
3+
import { TEST_ACCOUNT_B } from "../../../test/src/test-wallets.js";
4+
import { typedData } from "../../../test/src/typed-data.js";
5+
import { sepolia } from "../../chains/chain-definitions/sepolia.js";
6+
import { getContract } from "../../contract/contract.js";
7+
import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js";
8+
import { sendTransaction } from "../../transaction/actions/send-transaction.js";
9+
import { engineAccount } from "./index.js";
10+
11+
describe.runIf(
12+
process.env.TW_SECRET_KEY &&
13+
process.env.ENGINE_URL &&
14+
process.env.ENGINE_AUTH_TOKEN &&
15+
process.env.ENGINE_WALLET_ADDRESS,
16+
)("Engine", () => {
17+
const engineAcc = engineAccount({
18+
engineUrl: process.env.ENGINE_URL as string,
19+
authToken: process.env.ENGINE_AUTH_TOKEN as string,
20+
walletAddress: process.env.ENGINE_WALLET_ADDRESS as string,
21+
chain: sepolia,
22+
});
23+
24+
it("should sign a message", async () => {
25+
const signature = await engineAcc.signMessage({
26+
message: "hello",
27+
});
28+
expect(signature).toBeDefined();
29+
});
30+
31+
it("should sign typed data", async () => {
32+
const signature = await engineAcc.signTypedData({
33+
...typedData.basic,
34+
});
35+
expect(signature).toBeDefined();
36+
});
37+
38+
it("should send a tx", async () => {
39+
const tx = await sendTransaction({
40+
account: engineAcc,
41+
transaction: {
42+
client: TEST_CLIENT,
43+
chain: sepolia,
44+
to: TEST_ACCOUNT_B.address,
45+
value: 0n,
46+
},
47+
});
48+
expect(tx).toBeDefined();
49+
});
50+
51+
it("should send a extension tx", async () => {
52+
const nftContract = getContract({
53+
client: TEST_CLIENT,
54+
chain: sepolia,
55+
address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
56+
});
57+
const claimTx = claimTo({
58+
contract: nftContract,
59+
to: TEST_ACCOUNT_B.address,
60+
tokenId: 0n,
61+
quantity: 1n,
62+
});
63+
const tx = await sendTransaction({
64+
account: engineAcc,
65+
transaction: claimTx,
66+
});
67+
expect(tx).toBeDefined();
68+
});
69+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { Chain } from "../../chains/types.js";
2+
import type { Hex } from "../../utils/encoding/hex.js";
3+
import { toHex } from "../../utils/encoding/hex.js";
4+
import type { Account, SendTransactionOption } from "../interfaces/wallet.js";
5+
6+
/**
7+
* Options for creating an engine account.
8+
*/
9+
export type EngineAccountOptions = {
10+
/**
11+
* The URL of your engine instance.
12+
*/
13+
engineUrl: string;
14+
/**
15+
* The auth token to use with the engine instance.
16+
*/
17+
authToken: string;
18+
/**
19+
* The backend wallet to use for sending transactions inside engine.
20+
*/
21+
walletAddress: string;
22+
/**
23+
* The chain to use for signing messages and typed data (smart backend wallet only).
24+
*/
25+
chain?: Chain;
26+
};
27+
28+
/**
29+
* Creates an account that uses your engine backend wallet for sending transactions and signing messages.
30+
*
31+
* @param options - The options for the engine account.
32+
* @returns An account that uses your engine backend wallet.
33+
*
34+
* @beta
35+
* @wallet
36+
*
37+
* @example
38+
* ```ts
39+
* import { engineAccount } from "thirdweb/wallets/engine";
40+
*
41+
* const engineAcc = engineAccount({
42+
* engineUrl: "https://engine.thirdweb.com",
43+
* authToken: "your-auth-token",
44+
* walletAddress: "0x...",
45+
* });
46+
*
47+
* // then use the account as you would any other account
48+
* const transaction = claimTo({
49+
* contract,
50+
* to: "0x...",
51+
* quantity: 1n,
52+
* });
53+
* const result = await sendTransaction({ transaction, account: engineAcc });
54+
* console.log("Transaction sent:", result.transactionHash);
55+
* ```
56+
*/
57+
export function engineAccount(options: EngineAccountOptions): Account {
58+
const { engineUrl, authToken, walletAddress, chain } = options;
59+
60+
// these are shared across all methods
61+
const headers: HeadersInit = {
62+
"x-backend-wallet-address": walletAddress,
63+
Authorization: `Bearer ${authToken}`,
64+
"Content-Type": "application/json",
65+
};
66+
67+
return {
68+
address: walletAddress,
69+
sendTransaction: async (transaction: SendTransactionOption) => {
70+
const ENGINE_URL = new URL(engineUrl);
71+
ENGINE_URL.pathname = `/backend-wallet/${transaction.chainId}/send-transaction`;
72+
73+
const engineData: Record<string, string | undefined> = {
74+
// add to address if we have it (is optional to pass to engine)
75+
toAddress: transaction.to || undefined,
76+
// engine wants a hex string here so we serialize it
77+
data: transaction.data || "0x",
78+
// value is always required
79+
value: toHex(transaction.value ?? 0n),
80+
};
81+
82+
// TODO: gas overrides etc?
83+
84+
const engineRes = await fetch(ENGINE_URL, {
85+
method: "POST",
86+
headers,
87+
body: JSON.stringify(engineData),
88+
});
89+
if (!engineRes.ok) {
90+
const body = await engineRes.text();
91+
throw new Error(
92+
`Engine request failed with status ${engineRes.status} - ${body}`,
93+
);
94+
}
95+
const engineJson = (await engineRes.json()) as {
96+
result: {
97+
queueId: string;
98+
};
99+
};
100+
101+
// wait for the queueId to be processed
102+
ENGINE_URL.pathname = `/transaction/status/${engineJson.result.queueId}`;
103+
const startTime = Date.now();
104+
const TIMEOUT_IN_MS = 5 * 60 * 1000; // 5 minutes in milliseconds
105+
106+
while (Date.now() - startTime < TIMEOUT_IN_MS) {
107+
const queueRes = await fetch(ENGINE_URL, {
108+
method: "GET",
109+
headers,
110+
});
111+
if (!queueRes.ok) {
112+
const body = await queueRes.text();
113+
throw new Error(
114+
`Engine request failed with status ${queueRes.status} - ${body}`,
115+
);
116+
}
117+
const queueJSON = (await queueRes.json()) as {
118+
result: {
119+
status: "queued" | "mined" | "cancelled" | "errored";
120+
transactionHash: Hex | null;
121+
userOpHash: Hex | null;
122+
errorMessage: string | null;
123+
};
124+
};
125+
126+
if (
127+
queueJSON.result.status === "errored" &&
128+
queueJSON.result.errorMessage
129+
) {
130+
throw new Error(queueJSON.result.errorMessage);
131+
}
132+
if (queueJSON.result.transactionHash) {
133+
return {
134+
transactionHash: queueJSON.result.transactionHash,
135+
};
136+
}
137+
// wait 1s before checking again
138+
await new Promise((resolve) => setTimeout(resolve, 1000));
139+
}
140+
throw new Error("Transaction timed out after 5 minutes");
141+
},
142+
signMessage: async ({ message }) => {
143+
let engineMessage: string | Hex;
144+
let isBytes = false;
145+
if (typeof message === "string") {
146+
engineMessage = message;
147+
} else {
148+
engineMessage = toHex(message.raw);
149+
isBytes = true;
150+
}
151+
152+
const ENGINE_URL = new URL(engineUrl);
153+
ENGINE_URL.pathname = "/backend-wallet/sign-message";
154+
const engineRes = await fetch(ENGINE_URL, {
155+
method: "POST",
156+
headers,
157+
body: JSON.stringify({
158+
message: engineMessage,
159+
isBytes,
160+
chainId: chain?.id,
161+
}),
162+
});
163+
if (!engineRes.ok) {
164+
const body = await engineRes.text();
165+
throw new Error(
166+
`Engine request failed with status ${engineRes.status} - ${body}`,
167+
);
168+
}
169+
const engineJson = (await engineRes.json()) as {
170+
result: Hex;
171+
};
172+
return engineJson.result;
173+
},
174+
signTypedData: async (_typedData) => {
175+
const ENGINE_URL = new URL(engineUrl);
176+
ENGINE_URL.pathname = "/backend-wallet/sign-typed-data";
177+
const engineRes = await fetch(ENGINE_URL, {
178+
method: "POST",
179+
headers,
180+
body: JSON.stringify({
181+
domain: _typedData.domain,
182+
types: _typedData.types,
183+
value: _typedData.message,
184+
}),
185+
});
186+
if (!engineRes.ok) {
187+
engineRes.body?.cancel();
188+
throw new Error(
189+
`Engine request failed with status ${engineRes.status}`,
190+
);
191+
}
192+
const engineJson = (await engineRes.json()) as {
193+
result: Hex;
194+
};
195+
return engineJson.result;
196+
},
197+
};
198+
}

packages/thirdweb/src/wallets/smart/lib/signing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ async function checkFor712Factory({
288288
* });
289289
* ```
290290
*
291-
* @wallets
291+
* @wallet
292292
*/
293293
export async function deploySmartAccount(args: {
294294
smartAccount: Account;

0 commit comments

Comments
 (0)