Skip to content

Commit bc01d7d

Browse files
authored
feat: Add idempotency key support (#463)
* feat: Add idempotency key support * pass idempotency key header on all write requests * remove from POST body * one sql migration fiel * migration w/ default
1 parent f802c1c commit bc01d7d

File tree

93 files changed

+1057
-753
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

93 files changed

+1057
-753
lines changed

src/db/transactions/queueTx.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import type {
2-
DeployTransaction,
3-
Transaction,
4-
} from "@thirdweb-dev/sdk";
1+
import type { DeployTransaction, Transaction } from "@thirdweb-dev/sdk";
52
import { ERC4337EthersSigner } from "@thirdweb-dev/wallets/dist/declarations/src/evm/connectors/smart-wallet/lib/erc4337-signer";
63
import { BigNumber } from "ethers";
74
import type { ContractExtension } from "../../schema/extension";
@@ -17,6 +14,7 @@ interface QueueTxParams {
1714
deployedContractAddress?: string;
1815
deployedContractType?: string;
1916
simulateTx?: boolean;
17+
idempotencyKey?: string;
2018
}
2119

2220
export const queueTx = async ({
@@ -27,6 +25,7 @@ export const queueTx = async ({
2725
deployedContractAddress,
2826
deployedContractType,
2927
simulateTx,
28+
idempotencyKey,
3029
}: QueueTxParams) => {
3130
// TODO: We need a much safer way of detecting if the transaction should be a user operation
3231
const isUserOp = !!(tx.getSigner as ERC4337EthersSigner).erc4337provider;
@@ -54,7 +53,8 @@ export const queueTx = async ({
5453
signerAddress,
5554
accountAddress,
5655
target,
57-
simulateTx
56+
simulateTx,
57+
idempotencyKey,
5858
});
5959

6060
return queueId;
@@ -67,7 +67,8 @@ export const queueTx = async ({
6767
...txData,
6868
fromAddress,
6969
toAddress,
70-
simulateTx
70+
simulateTx,
71+
idempotencyKey,
7172
});
7273

7374
return queueId;

src/db/transactions/queueTxRaw.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
import type { Prisma } from "@prisma/client";
1+
import type { Prisma, Transactions } from "@prisma/client";
2+
import { uuid } from "uuidv4";
23
import { PrismaTransaction } from "../../schema/prisma";
34
import { TransactionStatusEnum } from "../../server/schemas/transaction";
45
import { simulateTx } from "../../server/utils/simulateTx";
5-
import { reportUsage, UsageEventTxActionEnum } from "../../utils/usage";
6+
import { UsageEventTxActionEnum, reportUsage } from "../../utils/usage";
67
import { sendWebhooks } from "../../utils/webhook";
78
import { getPrismaWithPostgresTx } from "../client";
89
import { getWalletDetails } from "../wallets/getWalletDetails";
910

1011
type QueueTxRawParams = Omit<
1112
Prisma.TransactionsCreateInput,
1213
"fromAddress" | "signerAddress"
13-
> & {
14-
pgtx?: PrismaTransaction;
15-
simulateTx?: boolean;
16-
} & (
14+
> &
15+
(
1716
| {
1817
fromAddress: string;
1918
signerAddress?: never;
@@ -22,13 +21,18 @@ type QueueTxRawParams = Omit<
2221
fromAddress?: never;
2322
signerAddress: string;
2423
}
25-
);
24+
) & {
25+
pgtx?: PrismaTransaction;
26+
simulateTx?: boolean;
27+
idempotencyKey?: string;
28+
};
2629

2730
export const queueTxRaw = async ({
28-
simulateTx: shouldSimulate,
2931
pgtx,
32+
simulateTx: shouldSimulate,
33+
idempotencyKey,
3034
...tx
31-
}: QueueTxRawParams) => {
35+
}: QueueTxRawParams): Promise<Transactions> => {
3236
const prisma = getPrismaWithPostgresTx(pgtx);
3337

3438
const walletDetails = await getWalletDetails({
@@ -48,21 +52,42 @@ export const queueTxRaw = async ({
4852
await simulateTx({ txRaw: tx });
4953
}
5054

51-
const insertedData = await prisma.transactions.create({
52-
data: {
53-
...tx,
54-
fromAddress: tx.fromAddress?.toLowerCase(),
55-
toAddress: tx.toAddress?.toLowerCase(),
56-
target: tx.target?.toLowerCase(),
57-
signerAddress: tx.signerAddress?.toLowerCase(),
58-
accountAddress: tx.accountAddress?.toLowerCase(),
59-
},
60-
});
55+
const insertData = {
56+
...tx,
57+
id: uuid(),
58+
fromAddress: tx.fromAddress?.toLowerCase(),
59+
toAddress: tx.toAddress?.toLowerCase(),
60+
target: tx.target?.toLowerCase(),
61+
signerAddress: tx.signerAddress?.toLowerCase(),
62+
accountAddress: tx.accountAddress?.toLowerCase(),
63+
};
64+
65+
let txRow: Transactions;
66+
if (idempotencyKey) {
67+
// Upsert the tx (insert if not exists).
68+
txRow = await prisma.transactions.upsert({
69+
where: { idempotencyKey },
70+
create: {
71+
...insertData,
72+
idempotencyKey,
73+
},
74+
update: {},
75+
});
76+
} else {
77+
// Insert the tx.
78+
txRow = await prisma.transactions.create({
79+
data: {
80+
...insertData,
81+
// Use queueId to ensure uniqueness.
82+
idempotencyKey: insertData.id,
83+
},
84+
});
85+
}
6186

6287
// Send queued webhook.
6388
sendWebhooks([
6489
{
65-
queueId: insertedData.id,
90+
queueId: txRow.id,
6691
status: TransactionStatusEnum.Queued,
6792
},
6893
]).catch((err) => {});
@@ -82,5 +107,5 @@ export const queueTxRaw = async ({
82107
},
83108
]);
84109

85-
return insertedData;
110+
return txRow;
86111
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
Warnings:
3+
4+
- A unique constraint covering the columns `[idempotencyKey]` on the table `transactions` will be added. If there are existing duplicate values, this will fail.
5+
6+
*/
7+
-- AlterTable
8+
ALTER TABLE "transactions" ADD COLUMN "idempotencyKey" TEXT NOT NULL DEFAULT gen_random_uuid();
9+
10+
-- CreateIndex
11+
CREATE UNIQUE INDEX "transactions_idempotencyKey_key" ON "transactions"("idempotencyKey");

src/prisma/schema.prisma

Lines changed: 64 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,25 @@ generator client {
88
}
99

1010
model Configuration {
11-
id String @id @default("default") @map("id")
11+
id String @id @default("default") @map("id")
1212
// Chains
13-
chainOverrides String? @map("chainOverrides")
13+
chainOverrides String? @map("chainOverrides")
1414
// Tx Processing
15-
minTxsToProcess Int @map("minTxsToProcess")
16-
maxTxsToProcess Int @map("maxTxsToProcess")
15+
minTxsToProcess Int @map("minTxsToProcess")
16+
maxTxsToProcess Int @map("maxTxsToProcess")
1717
// Tx Updates
18-
minedTxListenerCronSchedule String? @map("minedTxsCronSchedule")
19-
maxTxsToUpdate Int @map("maxTxsToUpdate")
18+
minedTxListenerCronSchedule String? @map("minedTxsCronSchedule")
19+
maxTxsToUpdate Int @map("maxTxsToUpdate")
2020
// Tx Retries
21-
retryTxListenerCronSchedule String? @map("retryTxsCronSchedule")
22-
minEllapsedBlocksBeforeRetry Int @map("minEllapsedBlocksBeforeRetry")
23-
maxFeePerGasForRetries String @map("maxFeePerGasForRetries")
24-
maxPriorityFeePerGasForRetries String @map("maxPriorityFeePerGasForRetries")
25-
maxRetriesPerTx Int @map("maxRetriesPerTx")
21+
retryTxListenerCronSchedule String? @map("retryTxsCronSchedule")
22+
minEllapsedBlocksBeforeRetry Int @map("minEllapsedBlocksBeforeRetry")
23+
maxFeePerGasForRetries String @map("maxFeePerGasForRetries")
24+
maxPriorityFeePerGasForRetries String @map("maxPriorityFeePerGasForRetries")
25+
maxRetriesPerTx Int @map("maxRetriesPerTx")
2626
// Contract Indexer Updates
27-
indexerListenerCronSchedule String? @map("indexerListenerCronSchedule")
28-
maxBlocksToIndex Int @default(25) @map("maxBlocksToIndex")
29-
cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds")
27+
indexerListenerCronSchedule String? @map("indexerListenerCronSchedule")
28+
maxBlocksToIndex Int @default(25) @map("maxBlocksToIndex")
29+
cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds")
3030
3131
// AWS
3232
awsAccessKeyId String? @map("awsAccessKeyId")
@@ -103,6 +103,8 @@ model WalletNonce {
103103

104104
model Transactions {
105105
id String @id @default(uuid()) @map("id")
106+
// Backward compatibility: default to db-generated UUID.
107+
idempotencyKey String @unique @default(dbgenerated("gen_random_uuid()")) @map("idempotencyKey")
106108
groupId String? @map("groupId")
107109
chainId String @map("chainId")
108110
// Shared
@@ -183,87 +185,82 @@ model Relayers {
183185
}
184186

185187
model ContractSubscriptions {
186-
id String @id @default(uuid()) @map("id")
187-
chainId Int
188-
contractAddress String
188+
id String @id @default(uuid()) @map("id")
189+
chainId Int
190+
contractAddress String
189191
190-
createdAt DateTime @default(now())
191-
updatedAt DateTime @updatedAt
192-
deletedAt DateTime?
192+
createdAt DateTime @default(now())
193+
updatedAt DateTime @updatedAt
194+
deletedAt DateTime?
193195
194196
// optimize distinct lookups
195197
@@index([chainId])
196-
197198
@@map("contract_subscriptions")
198199
}
199200

200201
model ContractEventLogs {
201-
chainId Int
202-
blockNumber Int
203-
contractAddress String
204-
transactionHash String
205-
topic0 String?
206-
topic1 String?
207-
topic2 String?
208-
topic3 String?
209-
data String
210-
eventName String?
211-
decodedLog Json?
212-
timestamp DateTime
213-
transactionIndex Int
214-
logIndex Int
215-
createdAt DateTime @default(now())
216-
updatedAt DateTime @updatedAt
202+
chainId Int
203+
blockNumber Int
204+
contractAddress String
205+
transactionHash String
206+
topic0 String?
207+
topic1 String?
208+
topic2 String?
209+
topic3 String?
210+
data String
211+
eventName String?
212+
decodedLog Json?
213+
timestamp DateTime
214+
transactionIndex Int
215+
logIndex Int
216+
createdAt DateTime @default(now())
217+
updatedAt DateTime @updatedAt
217218
218219
@@id([transactionHash, logIndex])
219-
220-
@@map("contract_event_logs")
221-
222220
@@index([timestamp])
223221
@@index([blockNumber])
224222
@@index([contractAddress])
225223
@@index([topic0])
226224
@@index([topic1])
227225
@@index([topic2])
228226
@@index([topic3])
227+
@@map("contract_event_logs")
229228
}
230229

231230
model ContractTransactionReceipts {
232-
chainId Int
233-
blockNumber Int
234-
contractAddress String
235-
contractId String // ${chainId}:${contractAddress}
236-
transactionHash String
237-
blockHash String
238-
timestamp DateTime
239-
data String
240-
241-
to String
242-
from String
243-
value String
244-
transactionIndex Int
245-
246-
gasUsed String
247-
effectiveGasPrice String
248-
status Int
249-
250-
createdAt DateTime @default(now())
251-
updatedAt DateTime @updatedAt
231+
chainId Int
232+
blockNumber Int
233+
contractAddress String
234+
contractId String // ${chainId}:${contractAddress}
235+
transactionHash String
236+
blockHash String
237+
timestamp DateTime
238+
data String
239+
240+
to String
241+
from String
242+
value String
243+
transactionIndex Int
244+
245+
gasUsed String
246+
effectiveGasPrice String
247+
status Int
248+
249+
createdAt DateTime @default(now())
250+
updatedAt DateTime @updatedAt
252251
253252
@@unique([chainId, transactionHash])
254-
255253
@@index([contractId, timestamp])
256254
@@index([contractId, blockNumber])
257-
258255
@@map("contract_transaction_receipts")
259256
}
260257

261258
model ChainIndexers {
262-
chainId Int @id
263-
lastIndexedBlock Int
259+
chainId Int @id
260+
lastIndexedBlock Int
264261
265-
createdAt DateTime @default(now())
266-
updatedAt DateTime @updatedAt
262+
createdAt DateTime @default(now())
263+
updatedAt DateTime @updatedAt
267264
268265
@@map("chain_indexers")
269-
}
266+
}

src/server/routes/backend-wallet/sendTransaction.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
standardResponseSchema,
88
transactionWritesResponseSchema,
99
} from "../../schemas/sharedApiSchemas";
10-
import { walletAuthSchema } from "../../schemas/wallet";
10+
import { walletHeaderSchema } from "../../schemas/wallet";
1111
import { getChainIdFromChain } from "../../utils/chain";
1212

1313
const ParamsSchema = Type.Object({
@@ -52,7 +52,7 @@ export async function sendTransaction(fastify: FastifyInstance) {
5252
operationId: "sendTransaction",
5353
params: ParamsSchema,
5454
body: requestBodySchema,
55-
headers: Type.Omit(walletAuthSchema, ["x-account-address"]),
55+
headers: walletHeaderSchema,
5656
querystring: requestQuerystringSchema,
5757
response: {
5858
...standardResponseSchema,
@@ -63,7 +63,10 @@ export async function sendTransaction(fastify: FastifyInstance) {
6363
const { chain } = request.params;
6464
const { toAddress, data, value } = request.body;
6565
const { simulateTx } = request.query;
66-
const fromAddress = request.headers["x-backend-wallet-address"] as string;
66+
const {
67+
"x-backend-wallet-address": fromAddress,
68+
"x-idempotency-key": idempotencyKey,
69+
} = request.headers as Static<typeof walletHeaderSchema>;
6770
const chainId = await getChainIdFromChain(chain);
6871

6972
const { id: queueId } = await queueTxRaw({
@@ -72,7 +75,8 @@ export async function sendTransaction(fastify: FastifyInstance) {
7275
toAddress,
7376
data,
7477
value,
75-
simulateTx: simulateTx,
78+
simulateTx,
79+
idempotencyKey,
7680
});
7781

7882
reply.status(StatusCodes.OK).send({

0 commit comments

Comments
 (0)