ethers.js icon indicating copy to clipboard operation
ethers.js copied to clipboard

NonceManager store

Open kraikov opened this issue 1 year ago • 7 comments

Describe the Feature

Currently the NonceManager handles the nonce management in memory. Would you accept a PR to allow different stores for the nonce? For example Redis. This would help to manage the nonce in horizontal scaling. Right now that's not possible.

Code Example

export class NonceManager extends AbstractSigner {

    constructor(signer: Signer, store?: Store) {
       ...
    }
}

export type Store = {
  get<T>(key: string): Promise<T | undefined>;
  set<T>(key: string, data: T): Promise<void>;
};

kraikov avatar Feb 12 '24 07:02 kraikov

I probably won’t accept a Redis-specific it into the core, but it is certainly a valid candidate for an ancillary package (in the @ethers-ext/ org) or I could link to it from the docs.

There are a lot of extras I’d love to add to the NonceManager. I’ve messed around with a few API options, but the goal is to be able to create a dependency tree of transactions and to have an option to specify recovery actions if a transaction fails.

Something generic like this might be acceptable though. I will mull it over today. :)

ricmoo avatar Feb 13 '24 16:02 ricmoo

I have a Nonce Manager I made that I think will benefit a lot of people. I'll get it prepped and make the repo public. In the future I will be reworking the code to support node graphs, but i'll share the repo later today.

niZmosis avatar Feb 13 '24 16:02 niZmosis

@ricmoo my idea was to create it with a generic store, which could be injected to the NonceManager. For example the store could be in-memory (like at the moment) or Redis or any other persistent storage. Does this make sense?

In the first case (in-memory) you'd a store looking like this:

class InMemoryStore {
   private delta = 0;

   public increment() {
      delta++;
   }

   reset() {
      delta=0;
   }
}

in the other case you'd have

class RedisStore {
   private redis: Redis;
   private key = `nonce-manager-${address}`

   public increment() {
      await this.redis.client.incr(this.key);
   }

   reset() {
      await this.redis.client.set(this.key, 0);
   }
}

one way to handle failed transactions broadcast is to call reset or decrement the delta

kraikov avatar Feb 13 '24 21:02 kraikov

My nonce manager is designed to take in any kind of store. Zustand, redis, redux, mongodb, you just pass the store to the nonce manager. I am trying to get it to a point to make it public. I do use it on my dApp already and has been solid. I also have a Provider Manager to with load balancing and health tracking per node. I have the stores made for all those mentioned. This isn't a little file, it's a library. What are you using v5 or v6?

niZmosis avatar Feb 14 '24 00:02 niZmosis

@niZmosis that's sounds really nice and useful! I'm using v6

kraikov avatar Feb 15 '24 08:02 kraikov

Can you provide a snippet or documentation on how to use with redis?

damianobarbati avatar May 28 '24 12:05 damianobarbati

This is what I end up using:

import { RedisStore } from 'cache-manager-ioredis-yet';
import {
  AbstractSigner,
  BlockTag,
  Provider,
  Signer,
  TransactionRequest,
  TransactionResponse,
  TypedDataDomain,
  TypedDataField,
  defineProperties,
} from 'ethers';

/**
 *  A **NonceManager** wraps another [[Signer]] and automatically manages
 *  the nonce, ensuring serialized and sequential nonces are used during
 *  transaction.
 */
export class RedisNonceManager extends AbstractSigner {
  /**
   *  The Signer being managed.
   */
  signer!: Signer;

  #noncePromise: null | Promise<number>;

  /**
   *  Creates a new **NonceManager** to manage %%signer%%.
   */
  constructor(
    signer: Signer,
    private readonly redisStore: RedisStore,
  ) {
    super(signer.provider);
    defineProperties<RedisNonceManager>(this, { signer });

    this.#noncePromise = null;
  }

  async getAddress(): Promise<string> {
    return this.signer.getAddress();
  }

  connect(provider: null | Provider, redisStore?: RedisStore): RedisNonceManager {
    return new RedisNonceManager(this.signer.connect(provider), redisStore);
  }

  async getNonce(blockTag?: BlockTag): Promise<number> {
    if (blockTag === 'pending') {
      if (this.#noncePromise == null) {
        this.#noncePromise = super.getNonce('pending');
      }

      const key = await this.getRedisKey();
      const delta = await this.redisStore.get<number>(key);
      const nonce = await this.#noncePromise;
      return nonce + delta;
    }

    return super.getNonce(blockTag);
  }

  /**
   *  Manually increment the nonce. This may be useful when managng
   *  offline transactions.
   */
  async increment(): Promise<void> {
    const key = await this.getRedisKey();
    await this.redisStore.client.incr(key);
  }

  /**
   *  Resets the nonce, causing the **NonceManager** to reload the current
   *  nonce from the blockchain on the next transaction.
   */
  async reset(): Promise<void> {
    const key = await this.getRedisKey();
    await this.redisStore.client.set(key, 0);
    this.#noncePromise = null;
  }

  async sendTransaction(tx: TransactionRequest): Promise<TransactionResponse> {
    const noncePromise = this.getNonce('pending');
    await this.increment();

    tx = await this.signer.populateTransaction(tx);
    tx.nonce = await noncePromise;

    try {
      return await this.signer.sendTransaction(tx);
    } catch (error) {
      // If the transaction fails, reset the nonce
      await this.reset();
      throw error;
    }
  }

  async signTransaction(tx: TransactionRequest): Promise<string> {
    return this.signer.signTransaction(tx);
  }

  async signMessage(message: string | Uint8Array): Promise<string> {
    return this.signer.signMessage(message);
  }

  async signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string> {
    return this.signer.signTypedData(domain, types, value);
  }

  private async getRedisKey() {
    const address = await this.signer.getAddress();
    return `${RedisNonceManager.name}-nonce-${address}`;
  }
}

kraikov avatar Jun 25 '24 14:06 kraikov