Skip to content

Commit

Permalink
feat(common): add sendTransaction, add mempool queue to nonce manager (
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Oct 9, 2023
1 parent d2f8e94 commit 0660561
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 70 deletions.
7 changes: 7 additions & 0 deletions .changeset/few-brooms-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@latticexyz/common": minor
---

- Added a `sendTransaction` helper to mirror viem's `sendTransaction`, but with our nonce manager
- Added an internal mempool queue to `sendTransaction` and `writeContract` for better nonce handling
- Defaults block tag to `pending` for transaction simulation and transaction count (when initializing the nonce manager)
1 change: 1 addition & 0 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"chalk": "^5.2.0",
"debug": "^4.3.4",
"execa": "^7.0.0",
"p-queue": "^7.4.1",
"p-retry": "^5.1.2",
"prettier": "^2.8.4",
"prettier-plugin-solidity": "^1.1.2",
Expand Down
9 changes: 7 additions & 2 deletions packages/common/src/createNonceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BaseError, BlockTag, Client, Hex, NonceTooHighError, NonceTooLowError }
import { debug as parentDebug } from "./debug";
import { getNonceManagerId } from "./getNonceManagerId";
import { getTransactionCount } from "viem/actions";
import PQueue from "p-queue";

const debug = parentDebug.extend("createNonceManager");

Expand All @@ -17,12 +18,13 @@ export type CreateNonceManagerResult = {
nextNonce: () => number;
resetNonce: () => Promise<void>;
shouldResetNonce: (error: unknown) => boolean;
mempoolQueue: PQueue;
};

export function createNonceManager({
client,
address,
blockTag = "latest",
address, // TODO: rename to account?
blockTag = "pending",
broadcastChannelName,
}: CreateNonceManagerOptions): CreateNonceManagerResult {
const nonceRef = { current: -1 };
Expand Down Expand Up @@ -68,10 +70,13 @@ export function createNonceManager({
);
}

const mempoolQueue = new PQueue({ concurrency: 1 });

return {
hasNonce,
nextNonce,
resetNonce,
shouldResetNonce,
mempoolQueue,
};
}
22 changes: 18 additions & 4 deletions packages/common/src/getContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
Chain,
GetContractParameters,
GetContractReturnType,
Hex,
PublicClient,
Transport,
WalletClient,
WriteContractParameters,
getContract as viem_getContract,
} from "viem";
import { UnionOmit } from "./type-utils/common";
import { WriteContractOptions, writeContract } from "./writeContract";
import { writeContract } from "./writeContract";

// copied from viem because this isn't exported
// TODO: import from viem?
Expand All @@ -26,6 +27,12 @@ function getFunctionParameters(values: [args?: readonly unknown[], options?: obj
return { args, options };
}

export type ContractWrite = {
id: string;
request: WriteContractParameters;
result: Promise<Hex>;
};

export type GetContractOptions<
TTransport extends Transport,
TAddress extends Address,
Expand All @@ -35,7 +42,7 @@ export type GetContractOptions<
TPublicClient extends PublicClient<TTransport, TChain>,
TWalletClient extends WalletClient<TTransport, TChain, TAccount>
> = Required<GetContractParameters<TTransport, TChain, TAccount, TAbi, TPublicClient, TWalletClient, TAddress>> & {
onWrite?: WriteContractOptions["onWrite"];
onWrite?: (write: ContractWrite) => void;
};

// TODO: migrate away from this approach once we can hook into viem: https://github.com/wagmi-dev/viem/discussions/1230
Expand Down Expand Up @@ -72,6 +79,7 @@ export function getContract<

if (contract.write) {
// Replace write calls with our own. Implemented ~the same as viem, but adds better handling of nonces (via queue + retries).
let nextWriteId = 0;
contract.write = new Proxy(
{},
{
Expand All @@ -83,14 +91,20 @@ export function getContract<
]
) => {
const { args, options } = getFunctionParameters(parameters);
return writeContract(walletClient, {
const request = {
abi,
address,
functionName,
args,
...options,
onWrite,
} as unknown as WriteContractOptions<TAbi, typeof functionName, TChain, TAccount>);
} as unknown as WriteContractParameters<TAbi, typeof functionName, TChain, TAccount>;
const result = writeContract(walletClient, request);

const id = `${walletClient.chain.id}:${walletClient.account.address}:${nextWriteId++}`;
onWrite?.({ id, request: request as WriteContractParameters, result });

return result;
};
},
}
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/getNonceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ const nonceManagers = new Map<string, CreateNonceManagerResult>();

export async function getNonceManager({
client,
address,
blockTag = "latest",
address, // TODO: rename to account?
blockTag = "pending",
}: CreateNonceManagerOptions): Promise<CreateNonceManagerResult> {
const id = await getNonceManagerId({ client, address, blockTag });

Expand Down
1 change: 1 addition & 0 deletions packages/common/src/getNonceManagerId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function getNonceManagerId({
address: Hex;
blockTag: BlockTag;
}): Promise<string> {
// TODO: improve this so we don't have to call getChainId every time
const chainId = client.chain?.id ?? (await getChainId(client));
return `mud:createNonceManager:${chainId}:${getAddress(address)}:${blockTag}`;
}
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./hexToResource";
export * from "./readHex";
export * from "./resourceToHex";
export * from "./resourceTypes";
export * from "./sendTransaction";
export * from "./spliceHex";
export * from "./transportObserver";
export * from "./writeContract";
Expand Down
89 changes: 89 additions & 0 deletions packages/common/src/sendTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
Account,
CallParameters,
Chain,
Client,
SendTransactionParameters,
Transport,
WriteContractReturnType,
} from "viem";
import { call, sendTransaction as viem_sendTransaction } from "viem/actions";
import pRetry from "p-retry";
import { debug as parentDebug } from "./debug";
import { getNonceManager } from "./getNonceManager";
import { parseAccount } from "viem/accounts";

const debug = parentDebug.extend("sendTransaction");

// TODO: migrate away from this approach once we can hook into viem's nonce management: https://github.com/wagmi-dev/viem/discussions/1230

export async function sendTransaction<
TChain extends Chain | undefined,
TAccount extends Account | undefined,
TChainOverride extends Chain | undefined
>(
client: Client<Transport, TChain, TAccount>,
request: SendTransactionParameters<TChain, TAccount, TChainOverride>
): Promise<WriteContractReturnType> {
const rawAccount = request.account ?? client.account;
if (!rawAccount) {
// TODO: replace with viem AccountNotFoundError once its exported
throw new Error("No account provided");
}
const account = parseAccount(rawAccount);

const nonceManager = await getNonceManager({
client,
address: account.address,
blockTag: "pending",
});

async function prepare(): Promise<SendTransactionParameters<TChain, TAccount, TChainOverride>> {
if (request.gas) {
debug("gas provided, skipping simulate", request.to);
return request;
}

debug("simulating tx to", request.to);
await call(client, {
...request,
blockTag: "pending",
account,
} as CallParameters<TChain>);

// TODO: estimate gas

return request;
}

const preparedRequest = await prepare();

return await nonceManager.mempoolQueue.add(
() =>
pRetry(
async () => {
if (!nonceManager.hasNonce()) {
await nonceManager.resetNonce();
}

const nonce = nonceManager.nextNonce();
debug("sending tx with nonce", nonce, "to", preparedRequest.to);
return await viem_sendTransaction(client, { nonce, ...preparedRequest });
},
{
retries: 3,
onFailedAttempt: async (error) => {
// On nonce errors, reset the nonce and retry
if (nonceManager.shouldResetNonce(error)) {
debug("got nonce error, retrying", error.message);
await nonceManager.resetNonce();
return;
}
// TODO: prepare again if there are gas errors?
throw error;
},
}
),
{ throwOnTimeout: true }
);
}
96 changes: 34 additions & 62 deletions packages/common/src/writeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Account,
Chain,
Client,
Hex,
SimulateContractParameters,
Transport,
WriteContractParameters,
Expand All @@ -16,29 +15,6 @@ import { getNonceManager } from "./getNonceManager";
import { parseAccount } from "viem/accounts";

const debug = parentDebug.extend("writeContract");
let nextWriteId = 0;

export type ContractWrite<
TAbi extends Abi | readonly unknown[] = Abi,
TFunctionName extends string = string,
TChain extends Chain | undefined = Chain,
TAccount extends Account | undefined = Account | undefined,
TChainOverride extends Chain | undefined = Chain | undefined
> = {
id: string;
request: WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride>;
result: Promise<Hex>;
};

export type WriteContractOptions<
TAbi extends Abi | readonly unknown[] = Abi,
TFunctionName extends string = string,
TChain extends Chain | undefined = Chain,
TAccount extends Account | undefined = Account | undefined,
TChainOverride extends Chain | undefined = Chain | undefined
> = WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride> & {
onWrite?: (write: ContractWrite<TAbi, TFunctionName, TChain, TAccount, TChainOverride>) => void;
};

// TODO: migrate away from this approach once we can hook into viem's nonce management: https://github.com/wagmi-dev/viem/discussions/1230

Expand All @@ -50,71 +26,67 @@ export async function writeContract<
TChainOverride extends Chain | undefined
>(
client: Client<Transport, TChain, TAccount>,
{ onWrite, ...request_ }: WriteContractOptions<TAbi, TFunctionName, TChain, TAccount, TChainOverride>
request: WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride>
): Promise<WriteContractReturnType> {
const request = request_ as WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride>;

const account_ = request.account ?? client.account;
if (!account_) {
const rawAccount = request.account ?? client.account;
if (!rawAccount) {
// TODO: replace with viem AccountNotFoundError once its exported
throw new Error("No account provided");
}
const account = parseAccount(account_);
const account = parseAccount(rawAccount);

const nonceManager = await getNonceManager({
client,
address: account.address,
blockTag: "pending",
});

async function prepareWrite(): Promise<
WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride>
> {
if (request.gas) {
debug("gas provided, skipping simulate", request);
debug("gas provided, skipping simulate", request.functionName, request.address);
return request;
}

debug("simulating write", request);
debug("simulating", request.functionName, "at", request.address);
const result = await simulateContract<TChain, TAbi, TFunctionName, TChainOverride>(client, {
...request,
blockTag: "pending",
account,
} as unknown as SimulateContractParameters<TAbi, TFunctionName, TChain, TChainOverride>);

return result.request as unknown as WriteContractParameters<TAbi, TFunctionName, TChain, TAccount, TChainOverride>;
}

async function write(): Promise<Hex> {
const preparedWrite = await prepareWrite();
const preparedWrite = await prepareWrite();

return await pRetry(
async () => {
if (!nonceManager.hasNonce()) {
await nonceManager.resetNonce();
}

const nonce = nonceManager.nextNonce();
debug("calling write function with nonce", nonce, preparedWrite);
return await viem_writeContract(client, { nonce, ...preparedWrite } as typeof preparedWrite);
},
{
retries: 3,
onFailedAttempt: async (error) => {
// On nonce errors, reset the nonce and retry
if (nonceManager.shouldResetNonce(error)) {
debug("got nonce error, retrying", error);
return nonceManager.mempoolQueue.add(
() =>
pRetry(
async () => {
if (!nonceManager.hasNonce()) {
await nonceManager.resetNonce();
return;
}
// TODO: prepareWrite again if there are gas errors?
throw error;
},
}
);
}

const result = write();

onWrite?.({ id: `${nextWriteId++}`, request, result });

return result;
const nonce = nonceManager.nextNonce();
debug("calling", preparedWrite.functionName, "with nonce", nonce, "at", preparedWrite.address);
return await viem_writeContract(client, { nonce, ...preparedWrite } as typeof preparedWrite);
},
{
retries: 3,
onFailedAttempt: async (error) => {
// On nonce errors, reset the nonce and retry
if (nonceManager.shouldResetNonce(error)) {
debug("got nonce error, retrying", error.message);
await nonceManager.resetNonce();
return;
}
// TODO: prepareWrite again if there are gas errors?
throw error;
},
}
),
{ throwOnTimeout: true }
);
}
Loading

0 comments on commit 0660561

Please sign in to comment.