Skip to content

Commit 97e1236

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

File tree

5 files changed

+292
-1
lines changed

5 files changed

+292
-1
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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
export function engineAccount(options: EngineAccountOptions): Account {
29+
const { engineUrl, authToken, walletAddress, chain } = options;
30+
31+
// these are shared across all methods
32+
const headers: HeadersInit = {
33+
"x-backend-wallet-address": walletAddress,
34+
Authorization: `Bearer ${authToken}`,
35+
"Content-Type": "application/json",
36+
};
37+
38+
return {
39+
address: walletAddress,
40+
sendTransaction: async (transaction: SendTransactionOption) => {
41+
// this will be the baseline URL for the requests
42+
const ENGINE_URL = new URL(engineUrl);
43+
44+
const engineData: Record<string, string | undefined> = {
45+
// add to address if we have it (is optional to pass to engine)
46+
toAddress: transaction.to || undefined,
47+
// engine wants a hex string here so we serialize it
48+
data: transaction.data || "0x",
49+
// value is always required
50+
value: toHex(transaction.value ?? 0n),
51+
};
52+
53+
// TODO: gas overrides etc?
54+
55+
ENGINE_URL.pathname = `/backend-wallet/${transaction.chainId}/send-transaction`;
56+
const engineRes = await fetch(ENGINE_URL, {
57+
method: "POST",
58+
headers,
59+
body: JSON.stringify(engineData),
60+
});
61+
if (!engineRes.ok) {
62+
const body = await engineRes.text();
63+
throw new Error(
64+
`Engine request failed with status ${engineRes.status} - ${body}`,
65+
);
66+
}
67+
const engineJson = (await engineRes.json()) as {
68+
result: {
69+
queueId: string;
70+
};
71+
};
72+
73+
// wait for the queueId to be processed
74+
ENGINE_URL.pathname = `/transaction/status/${engineJson.result.queueId}`;
75+
const startTime = Date.now();
76+
const TIMEOUT_IN_MS = 5 * 60 * 1000; // 5 minutes in milliseconds
77+
78+
while (Date.now() - startTime < TIMEOUT_IN_MS) {
79+
const queueRes = await fetch(ENGINE_URL, {
80+
method: "GET",
81+
headers,
82+
});
83+
if (!queueRes.ok) {
84+
const body = await queueRes.text();
85+
throw new Error(
86+
`Engine request failed with status ${queueRes.status} - ${body}`,
87+
);
88+
}
89+
const queueJSON = (await queueRes.json()) as {
90+
result: {
91+
status: "queued" | "mined" | "cancelled" | "errored";
92+
transactionHash: Hex | null;
93+
userOpHash: Hex | null;
94+
errorMessage: string | null;
95+
};
96+
};
97+
98+
if (
99+
queueJSON.result.status === "errored" &&
100+
queueJSON.result.errorMessage
101+
) {
102+
throw new Error(queueJSON.result.errorMessage);
103+
}
104+
if (queueJSON.result.transactionHash) {
105+
return {
106+
transactionHash: queueJSON.result.transactionHash,
107+
};
108+
}
109+
// wait 1s before checking again
110+
await new Promise((resolve) => setTimeout(resolve, 1000));
111+
}
112+
throw new Error("Transaction timed out after 5 minutes");
113+
},
114+
signMessage: async ({ message }) => {
115+
let engineMessage: string | Hex;
116+
let isBytes = false;
117+
if (typeof message === "string") {
118+
engineMessage = message;
119+
} else {
120+
engineMessage = toHex(message.raw);
121+
isBytes = true;
122+
}
123+
124+
// this will be the baseline URL for the requests
125+
const ENGINE_URL = new URL(engineUrl);
126+
// set the pathname correctly
127+
// see: https://redocly.github.io/redoc/?url=https://demo.web3api.thirdweb.com/json#tag/Backend-Wallet/operation/signMessage
128+
ENGINE_URL.pathname = "/backend-wallet/sign-message";
129+
const engineRes = await fetch(ENGINE_URL, {
130+
method: "POST",
131+
headers,
132+
body: JSON.stringify({
133+
message: engineMessage,
134+
isBytes,
135+
chainId: chain?.id,
136+
}),
137+
});
138+
if (!engineRes.ok) {
139+
const body = await engineRes.text();
140+
throw new Error(
141+
`Engine request failed with status ${engineRes.status} - ${body}`,
142+
);
143+
}
144+
const engineJson = (await engineRes.json()) as {
145+
result: Hex;
146+
};
147+
return engineJson.result;
148+
},
149+
signTypedData: async (_typedData) => {
150+
// this will be the baseline URL for the requests
151+
const ENGINE_URL = new URL(engineUrl);
152+
// set the pathname correctly
153+
// see: https://redocly.github.io/redoc/?url=https://demo.web3api.thirdweb.com/json#tag/Backend-Wallet/operation/signTypedData
154+
ENGINE_URL.pathname = "/backend-wallet/sign-typed-data";
155+
const engineRes = await fetch(ENGINE_URL, {
156+
method: "POST",
157+
headers,
158+
body: JSON.stringify({
159+
domain: _typedData.domain,
160+
types: _typedData.types,
161+
value: _typedData.message,
162+
}),
163+
});
164+
if (!engineRes.ok) {
165+
engineRes.body?.cancel();
166+
throw new Error(
167+
`Engine request failed with status ${engineRes.status}`,
168+
);
169+
}
170+
const engineJson = (await engineRes.json()) as {
171+
result: Hex;
172+
};
173+
return engineJson.result;
174+
},
175+
};
176+
}

0 commit comments

Comments
 (0)