Skip to content

Commit 72760c3

Browse files
authored
feat: batching for reads and smart-account writes (#773)
* Refactor: Add readMulticall route for batch contract reads * bump thirdweb + serialise bigint * Address review comments * fixed import * feat: add batch operations support for transactions and enhance wallet details retrieval * SDK changes * Address review comments
1 parent db40029 commit 72760c3

File tree

15 files changed

+553
-28
lines changed

15 files changed

+553
-28
lines changed

sdk/src/services/BackendWalletService.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,78 @@ export class BackendWalletService {
629629
});
630630
}
631631

632+
/**
633+
* Send a batch of raw transactions atomically
634+
* Send a batch of raw transactions in a single UserOp. Transactions will be sent in-order and atomically. Can only be used with smart wallets.
635+
* @param chain A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred.
636+
* @param xBackendWalletAddress Backend wallet address
637+
* @param requestBody
638+
* @param simulateTx Simulates the transaction before adding it to the queue, returning an error if it fails simulation. Note: This step is less performant and recommended only for debugging purposes.
639+
* @param xIdempotencyKey Transactions submitted with the same idempotency key will be de-duplicated. Only the last 100000 transactions are compared.
640+
* @param xAccountAddress Smart account address
641+
* @param xAccountFactoryAddress Smart account factory address. If omitted, Engine will try to resolve it from the contract.
642+
* @param xAccountSalt Smart account salt as string or hex. This is used to predict the smart account address. Useful when creating multiple accounts with the same admin and only needed when deploying the account as part of a userop.
643+
* @returns any Default Response
644+
* @throws ApiError
645+
*/
646+
public sendTransactionBatchAtomic(
647+
chain: string,
648+
xBackendWalletAddress: string,
649+
requestBody: {
650+
transactions: Array<{
651+
/**
652+
* A contract or wallet address
653+
*/
654+
toAddress?: string;
655+
/**
656+
* A valid hex string
657+
*/
658+
data: string;
659+
/**
660+
* An amount in wei (no decimals). Example: "50000000000"
661+
*/
662+
value: string;
663+
}>;
664+
},
665+
simulateTx: boolean = false,
666+
xIdempotencyKey?: string,
667+
xAccountAddress?: string,
668+
xAccountFactoryAddress?: string,
669+
xAccountSalt?: string,
670+
): CancelablePromise<{
671+
result: {
672+
/**
673+
* Queue ID
674+
*/
675+
queueId: string;
676+
};
677+
}> {
678+
return this.httpRequest.request({
679+
method: 'POST',
680+
url: '/backend-wallet/{chain}/send-transaction-batch-atomic',
681+
path: {
682+
'chain': chain,
683+
},
684+
headers: {
685+
'x-backend-wallet-address': xBackendWalletAddress,
686+
'x-idempotency-key': xIdempotencyKey,
687+
'x-account-address': xAccountAddress,
688+
'x-account-factory-address': xAccountFactoryAddress,
689+
'x-account-salt': xAccountSalt,
690+
},
691+
query: {
692+
'simulateTx': simulateTx,
693+
},
694+
body: requestBody,
695+
mediaType: 'application/json',
696+
errors: {
697+
400: `Bad Request`,
698+
404: `Not Found`,
699+
500: `Internal Server Error`,
700+
},
701+
});
702+
}
703+
632704
/**
633705
* Sign a transaction
634706
* Sign a transaction
@@ -833,6 +905,7 @@ export class BackendWalletService {
833905
onchainStatus: ('success' | 'reverted' | null);
834906
effectiveGasPrice: (string | null);
835907
cumulativeGasUsed: (string | null);
908+
batchOperations: null;
836909
}>;
837910
};
838911
}> {
@@ -928,6 +1001,7 @@ export class BackendWalletService {
9281001
onchainStatus: ('success' | 'reverted' | null);
9291002
effectiveGasPrice: (string | null);
9301003
cumulativeGasUsed: (string | null);
1004+
batchOperations: null;
9311005
} | string);
9321006
}>;
9331007
}> {

sdk/src/services/ContractService.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,50 @@ export class ContractService {
4646
});
4747
}
4848

49+
/**
50+
* Batch read from multiple contracts
51+
* Execute multiple contract read operations in a single call using Multicall
52+
* @param chain
53+
* @param requestBody
54+
* @returns any Default Response
55+
* @throws ApiError
56+
*/
57+
public readBatch(
58+
chain: string,
59+
requestBody: {
60+
calls: Array<{
61+
contractAddress: string;
62+
functionName: string;
63+
functionAbi?: string;
64+
args?: Array<any>;
65+
}>;
66+
/**
67+
* Address of the multicall contract to use. If omitted, multicall3 contract will be used (0xcA11bde05977b3631167028862bE2a173976CA11).
68+
*/
69+
multicallAddress?: string;
70+
},
71+
): CancelablePromise<{
72+
results: Array<{
73+
success: boolean;
74+
result: any;
75+
}>;
76+
}> {
77+
return this.httpRequest.request({
78+
method: 'POST',
79+
url: '/contract/{chain}/read-batch',
80+
path: {
81+
'chain': chain,
82+
},
83+
body: requestBody,
84+
mediaType: 'application/json',
85+
errors: {
86+
400: `Bad Request`,
87+
404: `Not Found`,
88+
500: `Internal Server Error`,
89+
},
90+
});
91+
}
92+
4993
/**
5094
* Write to contract
5195
* Call a write function on a contract.

sdk/src/services/TransactionService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class TransactionService {
7878
onchainStatus: ('success' | 'reverted' | null);
7979
effectiveGasPrice: (string | null);
8080
cumulativeGasUsed: (string | null);
81+
batchOperations: null;
8182
}>;
8283
totalCount: number;
8384
};
@@ -162,6 +163,7 @@ export class TransactionService {
162163
onchainStatus: ('success' | 'reverted' | null);
163164
effectiveGasPrice: (string | null);
164165
cumulativeGasUsed: (string | null);
166+
batchOperations: null;
165167
};
166168
}> {
167169
return this.httpRequest.request({
@@ -245,6 +247,7 @@ export class TransactionService {
245247
onchainStatus: ('success' | 'reverted' | null);
246248
effectiveGasPrice: (string | null);
247249
cumulativeGasUsed: (string | null);
250+
batchOperations: null;
248251
}>;
249252
totalCount: number;
250253
};

src/scripts/generate-sdk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import { kill } from "node:process";
55

66
const ENGINE_OPENAPI_URL = "https://demo.web3api.thirdweb.com/json";
7-
// const ENGINE_OPENAPI_URL = "http://localhost:3005/json";
7+
// const ENGINE_OPENAPI_URL = "http://127.0.0.1:3005/json";
88
const REPLACE_LOG_FILE = "sdk/replacement_log.txt";
99

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

0 commit comments

Comments
 (0)