Skip to content

Commit f24e84b

Browse files
committed
feat: add batch operations support for transactions and enhance wallet details retrieval
1 parent 82b8ce6 commit f24e84b

File tree

7 files changed

+243
-25
lines changed

7 files changed

+243
-25
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Type, type Static } from "@sinclair/typebox";
2+
import type { FastifyInstance } from "fastify";
3+
import { StatusCodes } from "http-status-codes";
4+
import type { Address, Hex } from "thirdweb";
5+
import { insertTransaction } from "../../../shared/utils/transaction/insert-transaction";
6+
import { AddressSchema } from "../../schemas/address";
7+
import {
8+
requestQuerystringSchema,
9+
standardResponseSchema,
10+
transactionWritesResponseSchema,
11+
} from "../../schemas/shared-api-schemas";
12+
import {
13+
maybeAddress,
14+
walletChainParamSchema,
15+
walletWithAAHeaderSchema,
16+
} from "../../schemas/wallet";
17+
import { getChainIdFromChain } from "../../utils/chain";
18+
import {
19+
getWalletDetails,
20+
isSmartBackendWallet,
21+
type ParsedWalletDetails,
22+
WalletDetailsError,
23+
} from "../../../shared/db/wallets/get-wallet-details";
24+
import { createCustomError } from "../../middleware/error";
25+
26+
const requestBodySchema = Type.Object({
27+
transactions: Type.Array(
28+
Type.Object({
29+
toAddress: Type.Optional(AddressSchema),
30+
data: Type.String({
31+
examples: ["0x..."],
32+
}),
33+
value: Type.String({
34+
examples: ["10000000"],
35+
}),
36+
}),
37+
),
38+
});
39+
40+
export async function sendTransactionsAtomicRoute(fastify: FastifyInstance) {
41+
fastify.route<{
42+
Params: Static<typeof walletChainParamSchema>;
43+
Body: Static<typeof requestBodySchema>;
44+
Reply: Static<typeof transactionWritesResponseSchema>;
45+
Querystring: Static<typeof requestQuerystringSchema>;
46+
}>({
47+
method: "POST",
48+
url: "/backend-wallet/:chain/send-transactions-atomic",
49+
schema: {
50+
summary: "Send a batch of raw transactions atomically",
51+
description:
52+
"Send a batch of raw transactions in a single UserOp. Can only be used with smart wallets.",
53+
tags: ["Backend Wallet"],
54+
operationId: "sendTransactionsAtomic",
55+
params: walletChainParamSchema,
56+
body: requestBodySchema,
57+
headers: Type.Omit(walletWithAAHeaderSchema, ["x-transaction-mode"]),
58+
querystring: requestQuerystringSchema,
59+
response: {
60+
...standardResponseSchema,
61+
[StatusCodes.OK]: transactionWritesResponseSchema,
62+
},
63+
},
64+
handler: async (request, reply) => {
65+
const { chain } = request.params;
66+
const {
67+
"x-backend-wallet-address": fromAddress,
68+
"x-idempotency-key": idempotencyKey,
69+
"x-account-address": accountAddress,
70+
"x-account-factory-address": accountFactoryAddress,
71+
"x-account-salt": accountSalt,
72+
} = request.headers as Static<typeof walletWithAAHeaderSchema>;
73+
const chainId = await getChainIdFromChain(chain);
74+
const shouldSimulate = request.query.simulateTx ?? false;
75+
const transactionRequests = request.body.transactions;
76+
77+
const hasSmartHeaders = !!accountAddress;
78+
79+
// check that we either use SBW, or send using EOA with smart wallet headers
80+
if (!hasSmartHeaders) {
81+
let backendWallet: ParsedWalletDetails | undefined;
82+
83+
try {
84+
backendWallet = await getWalletDetails({
85+
address: fromAddress,
86+
});
87+
} catch (e: unknown) {
88+
if (e instanceof WalletDetailsError) {
89+
throw createCustomError(
90+
`Failed to get wallet details for backend wallet ${fromAddress}. ${e.message}`,
91+
StatusCodes.BAD_REQUEST,
92+
"WALLET_DETAILS_ERROR",
93+
);
94+
}
95+
}
96+
97+
if (!backendWallet) {
98+
throw createCustomError(
99+
"Failed to get wallet details for backend wallet. See: https://portal.thirdweb.com/engine/troubleshooting",
100+
StatusCodes.INTERNAL_SERVER_ERROR,
101+
"WALLET_DETAILS_ERROR",
102+
);
103+
}
104+
105+
if (!isSmartBackendWallet(backendWallet)) {
106+
throw createCustomError(
107+
"Backend wallet is not a smart wallet, and x-account-address is not provided. Either use a smart backend wallet or provide x-account-address. This endpoint can only be used with smart wallets.",
108+
StatusCodes.BAD_REQUEST,
109+
"BACKEND_WALLET_NOT_SMART",
110+
);
111+
}
112+
}
113+
114+
if (transactionRequests.length === 0) {
115+
throw createCustomError(
116+
"No transactions provided",
117+
StatusCodes.BAD_REQUEST,
118+
"NO_TRANSACTIONS_PROVIDED",
119+
);
120+
}
121+
122+
const queueId = await insertTransaction({
123+
insertedTransaction: {
124+
transactionMode: undefined,
125+
isUserOp: false,
126+
chainId,
127+
from: fromAddress as Address,
128+
accountAddress: maybeAddress(accountAddress, "x-account-address"),
129+
accountFactoryAddress: maybeAddress(
130+
accountFactoryAddress,
131+
"x-account-factory-address",
132+
),
133+
accountSalt: accountSalt,
134+
batchOperations: transactionRequests.map((transactionRequest) => ({
135+
to: transactionRequest.toAddress as Address | undefined,
136+
data: transactionRequest.data as Hex,
137+
value: BigInt(transactionRequest.value),
138+
})),
139+
},
140+
shouldSimulate,
141+
idempotencyKey,
142+
});
143+
144+
reply.status(StatusCodes.OK).send({
145+
result: {
146+
queueId,
147+
},
148+
});
149+
},
150+
});
151+
}

src/server/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import { getAllWebhooksData } from "./webhooks/get-all";
112112
import { revokeWebhook } from "./webhooks/revoke";
113113
import { testWebhookRoute } from "./webhooks/test";
114114
import { readMulticallRoute } from "./contract/read/read-batch";
115+
import { sendTransactionsAtomicRoute } from "./backend-wallet/send-transactions-atomic";
115116

116117
export async function withRoutes(fastify: FastifyInstance) {
117118
// Backend Wallets
@@ -125,6 +126,7 @@ export async function withRoutes(fastify: FastifyInstance) {
125126
await fastify.register(withdraw);
126127
await fastify.register(sendTransaction);
127128
await fastify.register(sendTransactionBatch);
129+
await fastify.register(sendTransactionsAtomicRoute);
128130
await fastify.register(signTransaction);
129131
await fastify.register(signMessageRoute);
130132
await fastify.register(signTypedData);

src/server/schemas/transaction/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,16 @@ export const TransactionSchema = Type.Object({
210210
}),
211211
Type.Null(),
212212
]),
213+
batchOperations: Type.Union([
214+
Type.Array(
215+
Type.Object({
216+
to: Type.Union([Type.String(), Type.Null()]),
217+
data: Type.Union([Type.String(), Type.Null()]),
218+
value: Type.String(),
219+
}),
220+
),
221+
Type.Null(),
222+
]),
213223
});
214224

215225
export const toTransactionSchema = (
@@ -255,6 +265,17 @@ export const toTransactionSchema = (
255265
return null;
256266
};
257267

268+
const resolveBatchOperations = (): Static<
269+
typeof TransactionSchema
270+
>["batchOperations"] => {
271+
if (!transaction.batchOperations) return null;
272+
return transaction.batchOperations.map((op) => ({
273+
to: op.to ?? null,
274+
data: op.data ?? null,
275+
value: op.value.toString(),
276+
}));
277+
};
278+
258279
const resolveGas = (): string | null => {
259280
if (transaction.status === "sent") {
260281
return transaction.gas.toString();
@@ -351,6 +372,8 @@ export const toTransactionSchema = (
351372
userOpHash:
352373
"userOpHash" in transaction ? (transaction.userOpHash as Hex) : null,
353374

375+
batchOperations: resolveBatchOperations(),
376+
354377
// Deprecated
355378
retryGasValues: null,
356379
retryMaxFeePerGas: null,

src/shared/db/wallets/get-wallet-details.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import LruMap from "mnemonist/lru-map";
12
import { getAddress } from "thirdweb";
23
import { z } from "zod";
34
import type { PrismaTransaction } from "../../schemas/prisma";
@@ -130,6 +131,7 @@ export type SmartBackendWalletType = (typeof SmartBackendWalletTypes)[number];
130131
export type BackendWalletType = (typeof BackendWalletTypes)[number];
131132
export type ParsedWalletDetails = z.infer<typeof walletDetailsSchema>;
132133

134+
export const walletDetailsCache = new LruMap<string, ParsedWalletDetails>(2048);
133135
/**
134136
* Return the wallet details for the given address.
135137
*
@@ -143,20 +145,26 @@ export type ParsedWalletDetails = z.infer<typeof walletDetailsSchema>;
143145
*/
144146
export const getWalletDetails = async ({
145147
pgtx,
146-
address,
148+
address: _walletAddress,
147149
}: GetWalletDetailsParams) => {
150+
const walletAddress = _walletAddress.toLowerCase();
151+
const cachedDetails = walletDetailsCache.get(walletAddress);
152+
if (cachedDetails) {
153+
return cachedDetails;
154+
}
155+
148156
const prisma = getPrismaWithPostgresTx(pgtx);
149157
const config = await getConfig();
150158

151159
const walletDetails = await prisma.walletDetails.findUnique({
152160
where: {
153-
address: address.toLowerCase(),
161+
address: walletAddress.toLowerCase(),
154162
},
155163
});
156164

157165
if (!walletDetails) {
158166
throw new WalletDetailsError(
159-
`No wallet details found for address ${address}`,
167+
`No wallet details found for address ${walletAddress}`,
160168
);
161169
}
162170

@@ -167,7 +175,7 @@ export const getWalletDetails = async ({
167175
) {
168176
if (!walletDetails.awsKmsArn) {
169177
throw new WalletDetailsError(
170-
`AWS KMS ARN is missing for the wallet with address ${address}`,
178+
`AWS KMS ARN is missing for the wallet with address ${walletAddress}`,
171179
);
172180
}
173181

@@ -188,7 +196,7 @@ export const getWalletDetails = async ({
188196
) {
189197
if (!walletDetails.gcpKmsResourcePath) {
190198
throw new WalletDetailsError(
191-
`GCP KMS resource path is missing for the wallet with address ${address}`,
199+
`GCP KMS resource path is missing for the wallet with address ${walletAddress}`,
192200
);
193201
}
194202

@@ -209,14 +217,16 @@ export const getWalletDetails = async ({
209217

210218
// zod schema can validate all necessary fields are populated after decryption
211219
try {
212-
return walletDetailsSchema.parse(walletDetails, {
220+
const result = walletDetailsSchema.parse(walletDetails, {
213221
errorMap: (issue) => {
214222
const fieldName = issue.path.join(".");
215223
return {
216-
message: `${fieldName} is necessary for wallet ${address} of type ${walletDetails.type}, but not found in wallet details or configuration`,
224+
message: `${fieldName} is necessary for wallet ${walletAddress} of type ${walletDetails.type}, but not found in wallet details or configuration`,
217225
};
218226
},
219227
});
228+
walletDetailsCache.set(walletAddress, result);
229+
return result;
220230
} catch (e) {
221231
if (e instanceof z.ZodError) {
222232
throw new WalletDetailsError(

src/shared/utils/transaction/insert-transaction.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TransactionDB } from "../../../shared/db/transactions/db";
44
import {
55
getWalletDetails,
66
isSmartBackendWallet,
7+
WalletDetailsError,
78
type ParsedWalletDetails,
89
} from "../../../shared/db/wallets/get-wallet-details";
910
import { doesChainSupportService } from "../../lib/chain/chain-capabilities";
@@ -105,8 +106,12 @@ export const insertTransaction = async (
105106
entrypointAddress: walletDetails.entrypointAddress ?? undefined,
106107
};
107108
}
108-
} catch {
109-
// if wallet details are not found, this is a smart backend wallet using a v4 endpoint
109+
} catch (e) {
110+
if (e instanceof WalletDetailsError) {
111+
// do nothing. The this is a smart backend wallet using a v4 endpoint
112+
}
113+
// if other type of error, rethrow
114+
throw e;
110115
}
111116

112117
if (!walletDetails && queuedTransaction.accountAddress) {
@@ -139,13 +144,16 @@ export const insertTransaction = async (
139144
walletDetails.accountFactoryAddress ?? undefined,
140145
};
141146
}
142-
} catch {
147+
} catch (e: unknown) {
143148
// if wallet details are not found for this either, this backend wallet does not exist at all
144-
throw createCustomError(
145-
"Account not found",
146-
StatusCodes.BAD_REQUEST,
147-
"ACCOUNT_NOT_FOUND",
148-
);
149+
if (e instanceof WalletDetailsError) {
150+
throw createCustomError(
151+
"Account not found",
152+
StatusCodes.BAD_REQUEST,
153+
"ACCOUNT_NOT_FOUND",
154+
);
155+
}
156+
throw e;
149157
}
150158
}
151159

src/shared/utils/transaction/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export type AnyTransaction =
1313
| CancelledTransaction
1414
| ErroredTransaction;
1515

16+
export type BatchOperation = {
17+
to?: Address;
18+
value: bigint;
19+
data?: Hex;
20+
functionName?: string;
21+
functionArgs?: unknown[];
22+
};
23+
1624
// InsertedTransaction is the raw input from the caller.
1725
export type InsertedTransaction = {
1826
isUserOp: boolean;
@@ -49,6 +57,7 @@ export type InsertedTransaction = {
4957
accountSalt?: string;
5058
accountFactoryAddress?: Address;
5159
entrypointAddress?: Address;
60+
batchOperations?: BatchOperation[];
5261
target?: Address;
5362
sender?: Address;
5463
};

0 commit comments

Comments
 (0)