Skip to content

Commit

Permalink
refactor(common): move createContract's internal write logic to `wr…
Browse files Browse the repository at this point in the history
…iteContract` (#1693)
  • Loading branch information
holic committed Oct 3, 2023
1 parent 9352648 commit d075f82
Show file tree
Hide file tree
Showing 21 changed files with 328 additions and 220 deletions.
15 changes: 15 additions & 0 deletions .changeset/rotten-beers-learn.md
@@ -0,0 +1,15 @@
---
"@latticexyz/common": minor
---

- Moves contract write logic out of `createContract` into its own `writeContract` method so that it can be used outside of the contract instance, and for consistency with viem.
- Deprecates `createContract` in favor of `getContract` for consistency with viem.
- Reworks `createNonceManager`'s `BroadcastChannel` setup and moves out the notion of a "nonce manager ID" to `getNonceManagerId` so we can create an internal cache with `getNonceManager` for use in `writeContract`.

If you were using the `createNonceManager` before, you'll just need to rename `publicClient` argument to `client`:

```diff
const publicClient = createPublicClient({ ... });
- const nonceManager = createNonceManager({ publicClient, ... });
+ const nonceManager = createNonceManager({ client: publicClient, ... });
```
4 changes: 2 additions & 2 deletions e2e/packages/client-vanilla/src/mud/setupNetwork.ts
Expand Up @@ -4,7 +4,7 @@ import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs";
import { getNetworkConfig } from "./getNetworkConfig";
import { world } from "./world";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
import { createBurnerAccount, createContract, transportObserver } from "@latticexyz/common";
import { createBurnerAccount, getContract, transportObserver } from "@latticexyz/common";
import mudConfig from "contracts/mud.config";

export type SetupNetworkResult = Awaited<ReturnType<typeof setupNetwork>>;
Expand All @@ -26,7 +26,7 @@ export async function setupNetwork() {
account: burnerAccount,
});

const worldContract = createContract({
const worldContract = getContract({
address: networkConfig.worldAddress as Hex,
abi: IWorldAbi,
publicClient,
Expand Down
4 changes: 2 additions & 2 deletions e2e/packages/test-data/generate-test-data.ts
Expand Up @@ -13,7 +13,7 @@ import {
numberToHex,
} from "viem";
import { mudFoundry } from "@latticexyz/common/chains";
import { createContract } from "@latticexyz/common";
import { getContract } from "@latticexyz/common";
import { storeEventsAbi } from "@latticexyz/store";
import { privateKeyToAccount } from "viem/accounts";
import IWorldAbi from "../contracts/out/IWorld.sol/IWorld.abi.json";
Expand Down Expand Up @@ -63,7 +63,7 @@ const walletClient = createWalletClient({
account,
});

const worldContract = createContract({
const worldContract = getContract({
address: worldAddress,
abi: IWorldAbi,
publicClient,
Expand Down
Expand Up @@ -4,7 +4,7 @@ import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs";
import { getNetworkConfig } from "./getNetworkConfig";
import { world } from "./world";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
import { createBurnerAccount, createContract, transportObserver, ContractWrite } from "@latticexyz/common";
import { createBurnerAccount, getContract, transportObserver, ContractWrite } from "@latticexyz/common";
import { Subject, share } from "rxjs";
import mudConfig from "contracts/mud.config";

Expand All @@ -28,7 +28,7 @@ export async function setupNetwork() {
});

const write$ = new Subject<ContractWrite>();
const worldContract = createContract({
const worldContract = getContract({
address: networkConfig.worldAddress as Hex,
abi: IWorldAbi,
publicClient,
Expand Down
Expand Up @@ -4,7 +4,7 @@ import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs";
import { getNetworkConfig } from "./getNetworkConfig";
import { world } from "./world";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
import { ContractWrite, createBurnerAccount, createContract, transportObserver } from "@latticexyz/common";
import { ContractWrite, createBurnerAccount, getContract, transportObserver } from "@latticexyz/common";
import { Subject, share } from "rxjs";
import mudConfig from "contracts/mud.config";
import { createClient as createFaucetClient } from "@latticexyz/faucet";
Expand All @@ -29,7 +29,7 @@ export async function setupNetwork() {
});

const write$ = new Subject<ContractWrite>();
const worldContract = createContract({
const worldContract = getContract({
address: networkConfig.worldAddress as Hex,
abi: IWorldAbi,
publicClient,
Expand Down
Expand Up @@ -4,7 +4,7 @@ import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs";
import { getNetworkConfig } from "./getNetworkConfig";
import { world } from "./world";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
import { createBurnerAccount, createContract, transportObserver, ContractWrite } from "@latticexyz/common";
import { createBurnerAccount, getContract, transportObserver, ContractWrite } from "@latticexyz/common";
import { Subject, share } from "rxjs";
import mudConfig from "contracts/mud.config";

Expand All @@ -28,7 +28,7 @@ export async function setupNetwork() {
});

const write$ = new Subject<ContractWrite>();
const worldContract = createContract({
const worldContract = getContract({
address: networkConfig.worldAddress as Hex,
abi: IWorldAbi,
publicClient,
Expand Down
175 changes: 3 additions & 172 deletions packages/common/src/createContract.ts
@@ -1,173 +1,4 @@
import {
Abi,
Account,
Address,
Chain,
GetContractParameters,
GetContractReturnType,
Hex,
PublicClient,
SimulateContractParameters,
Transport,
WalletClient,
WriteContractParameters,
getContract,
} from "viem";
import pRetry from "p-retry";
import { createNonceManager } from "./createNonceManager";
import { debug as parentDebug } from "./debug";
import { UnionOmit } from "./type-utils/common";
import { getContract } from "./getContract";

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

// copied from viem because this isn't exported
// TODO: import from viem?
function getFunctionParameters(values: [args?: readonly unknown[], options?: object]): {
args: readonly unknown[];
options: object;
} {
const hasArgs = values.length && Array.isArray(values[0]);
const args = hasArgs ? values[0]! : [];
const options = (hasArgs ? values[1] : values[0]) ?? {};
return { args, options };
}

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

export type CreateContractOptions<
TTransport extends Transport,
TAddress extends Address,
TAbi extends Abi,
TChain extends Chain,
TAccount extends Account,
TPublicClient extends PublicClient<TTransport, TChain>,
TWalletClient extends WalletClient<TTransport, TChain, TAccount>
> = Required<GetContractParameters<TTransport, TChain, TAccount, TAbi, TPublicClient, TWalletClient, TAddress>> & {
onWrite?: (write: ContractWrite) => void;
};

export function createContract<
TTransport extends Transport,
TAddress extends Address,
TAbi extends Abi,
TChain extends Chain,
TAccount extends Account,
TPublicClient extends PublicClient<TTransport, TChain>,
TWalletClient extends WalletClient<TTransport, TChain, TAccount>
>({
abi,
address,
publicClient,
walletClient,
onWrite,
}: CreateContractOptions<
TTransport,
TAddress,
TAbi,
TChain,
TAccount,
TPublicClient,
TWalletClient
>): GetContractReturnType<TAbi, TPublicClient, TWalletClient, TAddress> {
const contract = getContract<TTransport, TAddress, TAbi, TChain, TAccount, TPublicClient, TWalletClient>({
abi,
address,
publicClient,
walletClient,
}) as unknown as GetContractReturnType<Abi, PublicClient, WalletClient>;

if (contract.write) {
let nextWriteId = 0;
const nonceManager = createNonceManager({
publicClient: publicClient as PublicClient,
address: walletClient.account.address,
});

// Replace write calls with our own proxy. Implemented ~the same as viem, but adds better handling of nonces (via queue + retries).
contract.write = new Proxy(
{},
{
get(_, functionName: string): GetContractReturnType<Abi, PublicClient, WalletClient>["write"][string] {
async function prepareWrite(
options: WriteContractParameters
): Promise<WriteContractParameters<TAbi, typeof functionName, TChain, TAccount>> {
if (options.gas) {
debug("gas provided, skipping simulate", functionName, options);
return options as unknown as WriteContractParameters<TAbi, typeof functionName, TChain, TAccount>;
}

debug("simulating write", functionName, options);
const { request } = await publicClient.simulateContract({
...options,
account: options.account ?? walletClient.account,
} as unknown as SimulateContractParameters<TAbi, typeof functionName, TChain>);

return request as unknown as WriteContractParameters<TAbi, typeof functionName, TChain, TAccount>;
}

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

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

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

return (...parameters) => {
const id = `${walletClient.chain.id}:${walletClient.account.address}:${nextWriteId++}`;
const { args, options } = <
{
args: unknown[];
options: UnionOmit<WriteContractParameters, "address" | "abi" | "functionName" | "args">;
}
>getFunctionParameters(parameters as any);

const request = {
address,
abi,
functionName,
args,
...options,
};

const result = write(request);

onWrite?.({ id, request, result });

return result;
};
},
}
);
}

return contract as unknown as GetContractReturnType<TAbi, TPublicClient, TWalletClient, TAddress>;
}
/** @deprecated use `getContract` instead */
export const createContract = getContract;
49 changes: 24 additions & 25 deletions packages/common/src/createNonceManager.ts
@@ -1,46 +1,45 @@
import {
BaseError,
BlockTag,
Hex,
NonceTooHighError,
NonceTooLowError,
PublicClient,
TransactionExecutionError,
} from "viem";
import { BaseError, BlockTag, Client, Hex, NonceTooHighError, NonceTooLowError } from "viem";
import { debug as parentDebug } from "./debug";
import { getNonceManagerId } from "./getNonceManagerId";
import { getTransactionCount } from "viem/actions";

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

type CreateNonceManagerOptions = {
publicClient: PublicClient;
export type CreateNonceManagerOptions = {
client: Client;
address: Hex;
blockTag?: BlockTag;
broadcastChannelName?: string;
};

type CreateNonceManagerResult = {
export type CreateNonceManagerResult = {
hasNonce: () => boolean;
nextNonce: () => number;
resetNonce: () => Promise<void>;
shouldResetNonce: (error: unknown) => boolean;
};

export function createNonceManager({
publicClient,
client,
address,
blockTag,
blockTag = "latest",
broadcastChannelName,
}: CreateNonceManagerOptions): CreateNonceManagerResult {
const nonceRef = { current: -1 };
const channel =
typeof BroadcastChannel !== "undefined"
? // TODO: fetch chain ID or require it via types?
new BroadcastChannel(`mud:createNonceManager:${publicClient.chain?.id}:${address}`)
: null;
let channel: BroadcastChannel | null = null;

if (channel) {
channel.addEventListener("message", (event) => {
const nonce = JSON.parse(event.data);
debug("got nonce from broadcast channel", nonce);
nonceRef.current = nonce;
if (typeof BroadcastChannel !== "undefined") {
const channelName = broadcastChannelName
? Promise.resolve(broadcastChannelName)
: getNonceManagerId({ client, address, blockTag });
channelName.then((name) => {
channel = new BroadcastChannel(name);
// TODO: emit some sort of "connected" event so other channels can broadcast current nonce
channel.addEventListener("message", (event) => {
const nonce = JSON.parse(event.data);
debug("got nonce from broadcast channel", nonce);
nonceRef.current = nonce;
});
});
}

Expand All @@ -56,7 +55,7 @@ export function createNonceManager({
}

async function resetNonce(): Promise<void> {
const nonce = await publicClient.getTransactionCount({ address, blockTag });
const nonce = await getTransactionCount(client, { address, blockTag });
nonceRef.current = nonce;
channel?.postMessage(JSON.stringify(nonceRef.current));
debug("reset nonce to", nonceRef.current);
Expand Down

1 comment on commit d075f82

@vercel
Copy link

@vercel vercel bot commented on d075f82 Oct 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.