Skip to content

Commit 1248acd

Browse files
authored
feat: add new debug endpoints to debug nonces (#635)
* feat: add new debug endpoints to debug nonces * add files * fixes while testing * reset-nonces will sync nonces * update resync logic
1 parent cc1b41b commit 1248acd

File tree

14 files changed

+381
-82
lines changed

14 files changed

+381
-82
lines changed

src/db/transactions/db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export class TransactionDB {
158158

159159
/**
160160
* Lists all transaction details by status.
161+
* Returns results paginated in descending order.
161162
* @param status "queued" | "mined" | "cancelled" | "errored"
162163
* @param page
163164
* @param limit

src/db/wallets/nonceMap.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Address } from "thirdweb";
2+
import { env } from "../../utils/env";
3+
import { normalizeAddress } from "../../utils/primitiveTypes";
4+
import { redis } from "../../utils/redis/redis";
5+
6+
/**
7+
* The "nonce map" sorted set stores the queue ID that acquired each nonce.
8+
* It is pruned to the latest 10k per wallet.
9+
*
10+
* Example:
11+
* {
12+
* "10": "e0fa731e-a947-4587-a48a-c56c02f8e7a8"
13+
* "11": "d111435a-1c0c-4308-ba40-59bad0868ee6"
14+
* }
15+
*/
16+
const nonceMapKey = (chainId: number, walletAddress: Address) =>
17+
`nonce-map:${chainId}:${normalizeAddress(walletAddress)}`;
18+
19+
export const updateNonceMap = async (args: {
20+
chainId: number;
21+
walletAddress: Address;
22+
nonce: number;
23+
queueId: string;
24+
}) => {
25+
const { chainId, walletAddress, nonce, queueId } = args;
26+
const key = nonceMapKey(chainId, walletAddress);
27+
await redis.zadd(key, nonce, queueId);
28+
};
29+
30+
/**
31+
* Returns (nonce, queueId) pairs sorted by ascending nonce for the
32+
* given wallet between the specified range.
33+
*/
34+
export const getNonceMap = async (args: {
35+
chainId: number;
36+
walletAddress: Address;
37+
fromNonce: number;
38+
toNonce?: number;
39+
}): Promise<{ nonce: number; queueId: string }[]> => {
40+
const { chainId, walletAddress, fromNonce, toNonce } = args;
41+
const key = nonceMapKey(chainId, walletAddress);
42+
43+
// Returns [ queueId1, nonce1, queueId2, nonce2, ... ]
44+
const elementsWithScores = await redis.zrangebyscore(
45+
key,
46+
fromNonce,
47+
// If toNonce is not provided, do not set an upper bound on the score.
48+
toNonce ?? "+inf",
49+
"WITHSCORES",
50+
);
51+
52+
const result: { nonce: number; queueId: string }[] = [];
53+
for (let i = 0; i < elementsWithScores.length; i += 2) {
54+
result.push({
55+
queueId: elementsWithScores[i],
56+
nonce: parseInt(elementsWithScores[i + 1]),
57+
});
58+
}
59+
return result;
60+
};
61+
62+
export const pruneNonceMaps = async () => {
63+
const pipeline = redis.pipeline();
64+
const keys = await redis.keys("nonce-map:*");
65+
for (const key of keys) {
66+
pipeline.zremrangebyrank(key, 0, -env.NONCE_MAP_COUNT);
67+
}
68+
const results = await pipeline.exec();
69+
if (!results) {
70+
return 0;
71+
}
72+
73+
let numDeleted = 0;
74+
for (const [error, result] of results) {
75+
if (!error) {
76+
numDeleted += parseInt(result as string);
77+
}
78+
}
79+
return numDeleted;
80+
};

src/db/wallets/walletNonce.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logger } from "../../utils/logger";
44
import { normalizeAddress } from "../../utils/primitiveTypes";
55
import { redis } from "../../utils/redis/redis";
66
import { thirdwebClient } from "../../utils/sdk";
7+
import { updateNonceMap } from "./nonceMap";
78

89
/**
910
* The "last used nonce" stores the last nonce submitted onchain.
@@ -76,30 +77,43 @@ export const isSentNonce = async (
7677
};
7778

7879
/**
79-
* Acquire an unused nonce.
80-
* This should be used to send an EOA transaction with this nonce.
80+
* Acquire an unused nonce to send an EOA transaction for the given backend wallet.
8181
* @param chainId
8282
* @param walletAddress
8383
* @returns number
8484
*/
85-
export const acquireNonce = async (
86-
chainId: number,
87-
walletAddress: Address,
88-
): Promise<{ nonce: number; isRecycledNonce: boolean }> => {
85+
export const acquireNonce = async (args: {
86+
queueId: string;
87+
chainId: number;
88+
walletAddress: Address;
89+
}): Promise<{ nonce: number; isRecycledNonce: boolean }> => {
90+
const { queueId, chainId, walletAddress } = args;
91+
92+
let isRecycledNonce = false;
93+
8994
// Try to acquire the lowest recycled nonce first
90-
const recycledNonce = await _acquireRecycledNonce(chainId, walletAddress);
91-
if (recycledNonce !== null) {
92-
return { nonce: recycledNonce, isRecycledNonce: true };
95+
let nonce = await _acquireRecycledNonce(chainId, walletAddress);
96+
if (nonce !== null) {
97+
isRecycledNonce = true;
98+
} else {
99+
// Else increment the last used nonce.
100+
const key = lastUsedNonceKey(chainId, walletAddress);
101+
nonce = await redis.incr(key);
102+
if (nonce === 1) {
103+
// If INCR returned 1, the nonce was not set.
104+
// This may be a newly imported wallet.
105+
// Sync the onchain value and increment again.
106+
await syncLatestNonceFromOnchain(chainId, walletAddress);
107+
nonce = await redis.incr(key);
108+
}
93109
}
94110

95-
// Else increment the last used nonce.
96-
const key = lastUsedNonceKey(chainId, walletAddress);
97-
let nonce = await redis.incr(key);
98-
if (nonce === 1) {
99-
// If INCR returned 1, the nonce was not set.
100-
// This may be a newly imported wallet.
101-
nonce = await _syncNonce(chainId, walletAddress);
102-
}
111+
await updateNonceMap({
112+
chainId,
113+
walletAddress,
114+
nonce,
115+
queueId,
116+
});
103117
return { nonce, isRecycledNonce: false };
104118
};
105119

@@ -146,10 +160,14 @@ const _acquireRecycledNonce = async (
146160
return parseInt(result[0]);
147161
};
148162

149-
const _syncNonce = async (
163+
/**
164+
* Resync the nonce to the onchain nonce.
165+
* @TODO: Redis lock this to make this method safe to call concurrently.
166+
*/
167+
export const syncLatestNonceFromOnchain = async (
150168
chainId: number,
151169
walletAddress: Address,
152-
): Promise<number> => {
170+
) => {
153171
const rpcRequest = getRpcClient({
154172
client: thirdwebClient,
155173
chain: await getChain(chainId),
@@ -162,8 +180,7 @@ const _syncNonce = async (
162180
});
163181

164182
const key = lastUsedNonceKey(chainId, walletAddress);
165-
await redis.set(key, transactionCount);
166-
return transactionCount;
183+
await redis.set(key, transactionCount - 1);
167184
};
168185

169186
/**
@@ -195,11 +212,12 @@ export const deleteAllNonces = async () => {
195212
};
196213

197214
/**
198-
* Resync the nonce i.e., max of transactionCount +1 and lastUsedNonce.
199-
* @param chainId
200-
* @param walletAddress
215+
* Resync the nonce to the higher of (db nonce, onchain nonce).
201216
*/
202-
export const resyncNonce = async (chainId: number, walletAddress: Address) => {
217+
export const syncLatestNonceFromOnchainIfHigher = async (
218+
chainId: number,
219+
walletAddress: Address,
220+
) => {
203221
const rpcRequest = getRpcClient({
204222
client: thirdwebClient,
205223
chain: await getChain(chainId),

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

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Static, Type } from "@sinclair/typebox";
22
import { FastifyInstance } from "fastify";
33
import { StatusCodes } from "http-status-codes";
4+
import { getAddress } from "thirdweb";
45
import { TransactionDB } from "../../../db/transactions/db";
5-
import { env } from "../../../utils/env";
6-
import { normalizeAddress } from "../../../utils/primitiveTypes";
6+
import { PaginationSchema } from "../../schemas/pagination";
77
import { standardResponseSchema } from "../../schemas/sharedApiSchemas";
88
import {
99
TransactionSchema,
@@ -12,41 +12,64 @@ import {
1212
import { walletWithAddressParamSchema } from "../../schemas/wallet";
1313
import { getChainIdFromChain } from "../../utils/chain";
1414

15-
const ParamsSchema = walletWithAddressParamSchema;
15+
const requestParamsSchema = walletWithAddressParamSchema;
16+
17+
const requestQuerySchema = Type.Object({
18+
...PaginationSchema.properties,
19+
status: Type.Union(
20+
[
21+
// Note: 'queued' returns all transcations, not just transactions currently queued.
22+
Type.Literal("queued"),
23+
Type.Literal("mined"),
24+
Type.Literal("cancelled"),
25+
Type.Literal("errored"),
26+
],
27+
{
28+
description:
29+
"The status to query: 'queued', 'mined', 'errored', or 'cancelled'. Default: 'queued'",
30+
default: "queued",
31+
},
32+
),
33+
});
1634

1735
const responseBodySchema = Type.Object({
1836
result: Type.Object({
1937
transactions: Type.Array(TransactionSchema),
2038
}),
2139
});
2240

23-
export async function getAllTransactions(fastify: FastifyInstance) {
41+
export async function getTransactionsForBackendWallet(
42+
fastify: FastifyInstance,
43+
) {
2444
fastify.route<{
25-
Params: Static<typeof ParamsSchema>;
45+
Querystring: Static<typeof requestQuerySchema>;
46+
Params: Static<typeof requestParamsSchema>;
2647
Reply: Static<typeof responseBodySchema>;
2748
}>({
2849
method: "GET",
2950
url: "/backend-wallet/:chain/:walletAddress/get-all-transactions",
3051
schema: {
31-
summary: "Get all transactions",
32-
description: "Get all transactions for a backend wallet.",
52+
summary: "Get recent transactions",
53+
description: "Get recent transactions for this backend wallet.",
3354
tags: ["Backend Wallet"],
34-
operationId: "getAllTransactions",
35-
params: ParamsSchema,
55+
operationId: "getTransactionsForBackendWallet",
56+
querystring: requestQuerySchema,
57+
params: requestParamsSchema,
3658
response: {
3759
...standardResponseSchema,
3860
[StatusCodes.OK]: responseBodySchema,
3961
},
4062
},
4163
handler: async (req, res) => {
4264
const { chain, walletAddress: _walletAddress } = req.params;
65+
const { page, limit, status } = req.query;
4366
const chainId = await getChainIdFromChain(chain);
44-
const walletAddress = normalizeAddress(_walletAddress);
67+
const walletAddress = getAddress(_walletAddress);
4568

4669
const { transactions } = await TransactionDB.getTransactionListByStatus({
47-
status: "queued",
48-
page: 1,
49-
limit: env.TRANSACTION_HISTORY_COUNT,
70+
status,
71+
page,
72+
limit,
5073
});
5174
const filtered = transactions.filter(
5275
(t) => t.chainId === chainId && t.from === walletAddress,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Static, Type } from "@sinclair/typebox";
2+
import { FastifyInstance } from "fastify";
3+
import { StatusCodes } from "http-status-codes";
4+
import { TransactionDB } from "../../../db/transactions/db";
5+
import { getNonceMap } from "../../../db/wallets/nonceMap";
6+
import { normalizeAddress } from "../../../utils/primitiveTypes";
7+
import { AnyTransaction } from "../../../utils/transaction/types";
8+
import { standardResponseSchema } from "../../schemas/sharedApiSchemas";
9+
import {
10+
TransactionSchema,
11+
toTransactionSchema,
12+
} from "../../schemas/transaction";
13+
import { walletWithAddressParamSchema } from "../../schemas/wallet";
14+
import { getChainIdFromChain } from "../../utils/chain";
15+
16+
const requestParamsSchema = walletWithAddressParamSchema;
17+
18+
const requestQuerySchema = Type.Object({
19+
fromNonce: Type.Integer({
20+
description: "The earliest nonce, inclusive.",
21+
examples: [100],
22+
minimum: 0,
23+
}),
24+
toNonce: Type.Optional(
25+
Type.Integer({
26+
description:
27+
"The latest nonce, inclusive. If omitted, queries up to the latest sent nonce.",
28+
examples: [100],
29+
minimum: 0,
30+
}),
31+
),
32+
});
33+
34+
const responseBodySchema = Type.Object({
35+
result: Type.Array(
36+
Type.Object({
37+
nonce: Type.Number(),
38+
// Returns the transaction details by default.
39+
// Falls back to the queueId if the transaction details have been pruned.
40+
transaction: Type.Union([TransactionSchema, Type.String()]),
41+
}),
42+
),
43+
});
44+
45+
export async function getTransactionsForBackendWalletByNonce(
46+
fastify: FastifyInstance,
47+
) {
48+
fastify.route<{
49+
Querystring: Static<typeof requestQuerySchema>;
50+
Params: Static<typeof requestParamsSchema>;
51+
Reply: Static<typeof responseBodySchema>;
52+
}>({
53+
method: "GET",
54+
url: "/backend-wallet/:chain/:walletAddress/get-transactions-by-nonce",
55+
schema: {
56+
summary: "Get recent transactions by nonce",
57+
description:
58+
"Get recent transactions for this backend wallet, sorted by descending nonce.",
59+
tags: ["Backend Wallet"],
60+
operationId: "getTransactionsForBackendWalletByNonce",
61+
querystring: requestQuerySchema,
62+
params: requestParamsSchema,
63+
response: {
64+
...standardResponseSchema,
65+
[StatusCodes.OK]: responseBodySchema,
66+
},
67+
},
68+
handler: async (req, res) => {
69+
const { chain, walletAddress: _walletAddress } = req.params;
70+
const { fromNonce, toNonce } = req.query;
71+
const chainId = await getChainIdFromChain(chain);
72+
const walletAddress = normalizeAddress(_walletAddress);
73+
74+
// Get queueIds.
75+
const nonceMap = await getNonceMap({
76+
chainId,
77+
walletAddress,
78+
fromNonce,
79+
toNonce,
80+
});
81+
82+
// Build map of { queueId => transaction }.
83+
const queueIds = nonceMap.map(({ queueId }) => queueId);
84+
const transactions = await TransactionDB.bulkGet(queueIds);
85+
const transactionsMap = new Map<string, AnyTransaction>();
86+
for (const transaction of transactions) {
87+
transactionsMap.set(transaction.queueId, transaction);
88+
}
89+
90+
// Hydrate the transaction, if found.
91+
const result = nonceMap.map(({ nonce, queueId }) => {
92+
const transaction = transactionsMap.get(queueId);
93+
return {
94+
nonce,
95+
transaction: transaction ? toTransactionSchema(transaction) : queueId,
96+
};
97+
});
98+
99+
res.status(StatusCodes.OK).send({
100+
result,
101+
});
102+
},
103+
});
104+
}

0 commit comments

Comments
 (0)