Skip to content

EVM: Split signer from account#40

Merged
AlonzoRicardo merged 59 commits into
tetherto:mainfrom
claudiovb:poc-signer
Jul 1, 2026
Merged

EVM: Split signer from account#40
AlonzoRicardo merged 59 commits into
tetherto:mainfrom
claudiovb:poc-signer

Conversation

@claudiovb

@claudiovb claudiovb commented Dec 3, 2025

Copy link
Copy Markdown
Contributor

Description

  • Introduces SeedSignerEvm and PrivateKeySignerEvm as provider-agnostic signers under src/signers/, both implementing the ISignerEvm interface (which extends the base ISigner from @tetherto/wdk-wallet).
  • Refactors WalletAccountEvm to accept a signer instead of a raw seed, separating signing logic from provider I/O. Adds a static fromSeed() and static fromPrivateKey() factory (its how to initialise the old way with the seed) and a lazy getAddress() that resolves and caches the address from the signer when it isn't known at construction time.
  • Refactors WalletManagerEvm to be signer-based with a backwards-compatible seedOrSigner constructor. The default signer must be derivable; non-derivable signers (e.g. private-key) can be registered by name via the inherited addSigner() and fetched with the string overload of getAccount(). getAccount is overloaded: a number selects an account index, a string selects a registered signer.
  • SeedSignerEvm always exposes an account (index 0 by default) and can derive children that share the HD root; derive() is async to honor the base ISigner contract. Derived children do not retain the root, and the manager never hands the root signer to an account — so disposing an account can never neuter the manager.
  • Adds populateTransactionEvm internal helper to build unsigned transactions across legacy (type 0/1), EIP-1559 (type 2), EIP-4844 (type 3), and EIP-7702 (type 4) styles.
  • Adds signAuthorization support for EIP-7702 delegation on both the seed and private-key signers.
  • Makes EvmTransaction.to optional and nullable (string | null), enabling contract-creation transactions (omit or pass null for to), matching ethers' TransactionRequest.
  • Fixes an HD derivation bug in MemorySafeHDNodeWallet: the child key now derives into a fresh buffer instead of mutating the parent's private key.
  • Fixes MemorySafeSigningKey.dispose() to guard against double-dispose.
  • Adds a package export for the ./signers subpath.
  • Updates the README with documentation covering the signer types, usage examples, and contract creation.
  • Adds hand-written type declarations (.d.ts) for both signers and populateTransactionEvm, and updates the existing declarations (wallet-account-evm.d.ts, wallet-manager-evm.d.ts, wallet-account-read-only-evm.d.ts) to reflect the refactor.
  • Updates unit and integration tests: adds PrivateKeySignerEvm coverage, shared-root disposal regression tests, a contract-creation test, and adapts the constructor/derive call sites.

Motivation and Context

  • Enable pluggable signers (software now; hardware and passkeys in follow-up work) by decoupling signing from provider I/O.
  • Ensure safe HD derivation (no shared mutable key buffers across parent/child) and safe disposal (disposing one account never breaks sibling accounts or the manager).
  • Make transaction building robust across all EVM transaction types — including contract creation — without relying on provider.populateTransaction.
  • Align the EVM module with BTC's signer-split design to support multi-device/multi-format signers.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Breaking Changes

  • WalletAccountEvm constructor now takes (signer, config) instead of (seed, path, config). Use WalletAccountEvm.fromSeed(seed, path, config) for the old behavior.
  • WalletManagerEvm constructor accepts seedOrSigner — raw strings/Uint8Array are auto-wrapped into a SeedSignerEvm for backwards compatibility, but passing a signer directly is now the recommended pattern. The default signer must be derivable.

Note

PS: Ledger integration (LedgerSignerEvm) is intentionally left out of this PR to keep it focused and easy to review. It will come in a separate PR from the poc-signer-ledger branch.

@claudiovb
claudiovb changed the base branch from main to develop December 3, 2025 22:36
@claudiovb
claudiovb marked this pull request as ready for review December 24, 2025 19:57

@sontuphan sontuphan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @claudiovb, I left some comments in the ledger-signer-evm.js. Please have a look when you have time.

Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread package.json Outdated
Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread src/signers/ledger-signer-evm.js Outdated
Comment thread types/src/signers/seed-signer-evm.d.ts Outdated
@AlonzoRicardo

Copy link
Copy Markdown
Contributor

ISignerEvm and UnsignedEvmTransaction show up in public signatures (the account/manager constructors and every signer method) but aren't re-exported from index.js / types/index.d.ts, so consumers can only reach them via deep subpath imports. Consider surfacing them from the package's public type entry points.

Comment thread tests/integration/module.test.js Outdated
@AlonzoRicardo
AlonzoRicardo merged commit de23adb into tetherto:main Jul 1, 2026
2 checks passed
This was referenced Jul 2, 2026
Comment thread package.json
sodium_memzero(this._privateKeyBuffer)

this._privateKeyBuffer = undefined
if (this._privateKeyBuffer) {

@Davi0kProgramsThings Davi0kProgramsThings Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's no need for the 'dispose' method to be idempotent. By definition, after disposing an object users should expect all methods to have undefined behavior which basically includes anything. We should just guarantee that such undefined behavior doesn't include any unsafe operations but since the private key is not available anymore this is not a possible scenario. These changes are not a mistake but we can safely revert them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd keep the guard, and I don't think this is about post-dispose undefined behavior... it's about disposal itself being safe to run from multiple owners. WalletManager.dispose() disposes all accounts (each of which disposes its signer) and then loops over the registered signers disposing them again. For a named non-derivable signer, the account's signer is the registered signer object, so manager.dispose() calls dispose() twice on the same object by design. I reproduced this: without the guard the second call crashes with sodium_memzero: buf must be a typed array. So reverting doesn't just make user double-dispose UB; it makes our own teardown path throw.

More generally, idempotent disposal is the standard contract precisely because cleanup graphs overlap: .NET's IDisposable mandates it ("must ignore all calls after the first one") and TC39's DisposableStack.dispose() is specified as a no-op when already disposed. The guard is two lines; I'd rather keep teardown order-independent than make every owner coordinate who disposes first. I also added double-dispose tests to both signer suites in #95 to lock the contract in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good point, this makes sense.

I have a suggestion for a different improvement. I'll post it here though, since i don't think there is a better place. Since the 'dispose' method now exists in multiple interfaces and abstract classes i.e., 'WalletManager', 'IWalletAccount', 'ISigner', it would be better to create an ad-hoc type for it and make them implement it. Your mention to .NET's IDisposable made me remember this improvement i thought about some weeks ago. Feel free to add these changes to wdk-wallet pr#52.

// /src/disposable.js
/** @interface */
export class IDisposable {
  /**
   * Disposes the object along with all its data (cleaning up any sensitive field from memory).
   */
  dispose () {
    throw new NotImplementedError('dispose()')
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice idea, done in wdk-wallet#52: added src/disposable.js with IDisposable as you spec'd, ISigner now extends it (its own dispose stub is gone), and IWalletAccount/WalletManager implement it. The concrete signers and accounts needed no changes, they already implement dispose() and inherit the contract through the interface chain.

Comment thread src/memory-safe/hd-node-wallet.js
Comment thread index.js
Comment thread src/utils/tx-populator-evm.js
Comment thread src/wallet-manager-evm.js
Comment thread src/wallet-manager-evm.js
Comment thread src/wallet-manager-evm.js
Comment on lines +141 to +144
const accountSigner = signer.isDerivable
? await signer.derive(signer.path.split('/').slice(-3).join('/'))
: signer
const account = new WalletAccountEvm(accountSigner, this._config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should derive the account at the given signer without any further derivation:

Suggested change
const accountSigner = signer.isDerivable
? await signer.derive(signer.path.split('/').slice(-3).join('/'))
: signer
const account = new WalletAccountEvm(accountSigner, this._config)
const account = new WalletAccountEvm(signer, this._config)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The address is identical either way and deriving the signer's own relative path yields the same account key. The detached child exists for ownership: the account must own key material it's allowed to dispose. With new WalletAccountEvm(signer, ...) handing the registered signer to the account, account.dispose() disposes the registered signer (wiping the root) and every later getAccountByPath(..., { signerName }) fails with "cannot derive child of neutered node". That's precisely the bug Ricardo's reported on a previous review, fixed by this ownership split. Note also that WalletManager.dispose() disposes accounts and then registered signers, so sharing the object would double-dispose it. There's a regression test covering exactly this in the manager suite ("should derive a detached account for a named derivable signer without handing out the root").

@Davi0kProgramsThings Davi0kProgramsThings Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remember that old discussion about why we are not wiping the seed when the user disposes a wallet manager? The exact same reason applies here as well. Basically, the WalletAccountEvm#dispose method should never wipe external signers. So, if a signer was given at construction we can't dispose it since the class doesn't "own" it and doing so would risk breaking other parts of the application which is exactly what is happening here. If the user creates a new account by providing a seed + path instead of a signer, then it's different since we will create the signer ourself and so we will be the actual owners. Only in this case, when the user calls the 'dispose' method we can wipe the signer. So, what happens is that users should take care of disposing their own signers just like they are already doing with their seeds.

The issue becomes more complex if we take the wallet manager into consideration though. In fact, we also "own" all the signers that we derive when the user's call the WalletManager#getAccount and getAccountByPath methods. So, we need a way to tell the 'WalletAccountEvm' class whether we own the given signer. We can quickly solve this by passing an extra option to its constructor without making it part of the public api i.e., without adding it to the 'EvmWalletConfig' type. In typescript or in other programming languages, this would be the equivalent of adding a constructor overload with package-private visibility that accepts a type that extends 'EvmWalletConfig' with this additional property. However, since we are using javascript we can just pass it and by not adding it to the type definitions it will be invisible to the end user. Basically, in the get account by path method we should change the construction of the account to:

  async getAccountByPath (path, options = {}) {
    const { signerName } = options
    const key = signerName ? `${signerName}:${path}` : path
    if (this._accounts[key]) {
      return this._accounts[key]
    }
    const signer = this.getSigner(signerName)
    const childSigner = await signer.derive(path)
    const account = new WalletAccountEvm(childSigner, { ...this._config, shouldWipeSignerOnDisposal: true })

The above works but it is not a really good design since third-party wallet managers wouldn't be able to see and use this property. In order to improve it, we can think about making this field public so that external code can view it as well. This is not bad since it also allows users to delegate our wallet accounts to wipe their own signers. This can be useful if the user knows that the given signer has no other uses in the application and so disposing it along with the account wouldn't break anything. In this case, we shouldn't add it to 'EvmWalletConfig' but rather define it in a new ad-hoc 'SignerOptions' type to accept only in the constructor overload that takes a signer instead of a seed + path:

/**
 * @typedef {Object} SignerOptions
 * @property {boolean} [shouldWipeSignerOnDisposal] - If true, wipes the signer given at construction on calls to the 'dispose' method.
 */

/** @implements {IWalletAccount} */
export default class WalletAccountEvm extends WalletAccountReadOnlyEvm {
  /**
   * Creates a new evm wallet account using a signer.
   *
   * @overload
   * @param {ISignerEvm} signer - A signer implementing the EVM signer interface.
   * @param {EvmWalletConfig & SignerOptions} [config] - The configuration object.
   */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With these changes, you will be able to apply the above suggestion. Note that we should only enable 'shouldWipeSignerOnDisposal' when the wallet manager derives new signers. When the user calls the get account method by using the overload that just takes a signer name we SHOULDN'T enable the flag, since we will construct the account with a signer that we don't own since it has been given to us by external code. This also applies in the case the user creates the wallet manager with the legacy constructor i.e., by providing a seed.

Comment thread src/wallet-account-evm.js
async getAddress () {
if (this._address) return this._address
const addr = await this._signer.getAddress()
this.__address = addr

@Davi0kProgramsThings Davi0kProgramsThings Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor mistake here:

Suggested change
this.__address = addr
this._address = addr

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one's intentional: _address is a getter-only accessor on WalletAccountReadOnly, backed by the __address field its constructor sets (line 128). Since ESM is strict mode, this._address = addr throws TypeError: Cannot set property _address of # which has only a getter. __address is the correct write target;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the super-class, __address is a private property so it's not really correct to access it in the sub-class. Also, the _address property is optional. This was made to allow sub-classes to not provide a value for it at construction and just provide the address by overriding the get address method. So, this can just be:

  /**
   * Returns the account's address.
   *
   * @returns {Promise<string>} The account's address.
   */
  async getAddress () {
    return await this._signer.getAddress()
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, but there's one thing we must settle before I remove the address property and drop the __address write inside WalletAccountEvm#getAddress: the public sync address getter on WalletAccountReadOnlyEvm would then return undefined for every WalletAccountEvm (today it's always set, and both our tests and downstream code read it). Do we:
(a) accept that and migrate callers to await getAddress(), keeping the getter meaningful only for read-only accounts constructed with an explicit address ( calling toReadOnlyAccount function) ?
(b) drop the sync getter from the account surface entirely?

Comment thread src/wallet-account-evm.js
Comment on lines 295 to 308
async signAuthorization (auth) {
return await this._account.authorize(auth)
const populated = { ...auth }
if (this._provider) {
if (populated.chainId == null) {
const { chainId } = await this._provider.getNetwork()
populated.chainId = chainId
}
if (populated.nonce == null) {
const address = await this.getAddress()
populated.nonce = await this._provider.getTransactionCount(address)
}
}
return await this._signer.signAuthorization(populated)
}

@Davi0kProgramsThings Davi0kProgramsThings Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

c.c. @AlonzoRicardo

Not sure if this is actually necessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's necessary, and it's a security guard. Our signers aren't provider-connected (by design, the account owns the provider), so ethers can't populate the authorization itself; and BaseWallet.authorizeSync defaults missing fields with auth.chainId || 0 and auth.nonce || 0. Per EIP-7702, a chainId-0 authorization is valid on every chain, so a user calling signAuthorization({ address }) without this populate step would unknowingly sign an any-chain delegation with a likely-wrong nonce. Populating chainId and nonce from the provider before signing is the safe default; explicit values pass through untouched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changing the behavior of a method depending on whether an optional property has been set is generally bad design. So, either we revert this change and put a warning in the documentation or we make the method throw if a provider has not been given at construction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on removing the conditional behavior, implemented the throw variant in #95, with one refinement: it throws when population is actually needed (chainId or nonce missing and no provider), rather than unconditionally when no provider is set. That keeps offline signing with explicit fields working, which is a legitimate flow, while making the dangerous case (signAuthorization({ address }) with no provider, which would have signed a chainId-0 any-chain authorization) a hard error. Both cases are covered by new tests. I thought maybe about an unconditional throw but the current approached seemed better


test('should sign a transaction and return a valid hex string', async () => {
const accountWithoutProvider = new WalletAccountEvm(SEED_PHRASE, "0'/0/0")
const accountWithoutProvider = new WalletAccountEvm(await new SeedSignerEvm(SEED_PHRASE).derive("0'/0/0"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should revert this change across all test files including integration tests. In order to cover the constructor overload that accepts a signer instead of a seed + path, you should just add a proper test case to the constructor test suite. You should also use a mock instead of an actual implementation since we should always isolate the SUT from any DOC.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in #95 : all test files (unit, manager, integration) are back on the seed+path overloads, and the signer overload is covered by a dedicated constructor test using a mock signer so the SUT is isolated from the DOC. But I kept the single sendTransaction-with-PrivateKeySignerEvm test as real-composition coverage as it's the only test exercising a non-derivable signer through the full account send path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But I kept the single sendTransaction-with-PrivateKeySignerEvm test as real-composition coverage as it's the only test exercising a non-derivable signer through the full account send path.

Get rid of this and use an integration test to cover initializating an account with a non-derivable signer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in #95 , the unit test is gone and the integration suite now covers registering a PrivateKeySignerEvm, getting its account via getAccount(signerName) and sending a transaction end-to-end.

Comment thread tests/wallet-account-evm.test.js
Comment thread src/wallet-account-evm.js
Comment on lines +136 to +142
/**
* Returns the account's address. If it wasn't resolved at construction time (e.g hardware signers), it asks the
* underlying signer to resolve it, then caches it locally.
*
* @returns {Promise<string>} The account's address.
*/
async getAddress () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

KISS:

Suggested change
/**
* Returns the account's address. If it wasn't resolved at construction time (e.g hardware signers), it asks the
* underlying signer to resolve it, then caches it locally.
*
* @returns {Promise<string>} The account's address.
*/
async getAddress () {
/**
* Returns the account's address.
*
* @returns {Promise<string>} The account's address.
*/
async getAddress () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in #95, plus the same treatment on a couple of other doc blocks that described internals (manager's named-signer overload, seed-signer constructor).

Comment thread src/wallet-account-evm.js

super(account.address, config)
constructor (signer, config = {}) {
super(signer.address, config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since we are removing the 'address' property, here you should change the super constructor call to:

Suggested change
super(signer.address, config)
super(undefined, config)


test('should sign a transaction and return a valid hex string', async () => {
const accountWithoutProvider = new WalletAccountEvm(SEED_PHRASE, "0'/0/0")
const accountWithoutProvider = new WalletAccountEvm(await new SeedSignerEvm(SEED_PHRASE).derive("0'/0/0"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But I kept the single sendTransaction-with-PrivateKeySignerEvm test as real-composition coverage as it's the only test exercising a non-derivable signer through the full account send path.

Get rid of this and use an integration test to cover initializating an account with a non-derivable signer.

Comment thread src/wallet-account-evm.js
Comment on lines 295 to 308
async signAuthorization (auth) {
return await this._account.authorize(auth)
const populated = { ...auth }
if (this._provider) {
if (populated.chainId == null) {
const { chainId } = await this._provider.getNetwork()
populated.chainId = chainId
}
if (populated.nonce == null) {
const address = await this.getAddress()
populated.nonce = await this._provider.getTransactionCount(address)
}
}
return await this._signer.signAuthorization(populated)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changing the behavior of a method depending on whether an optional property has been set is generally bad design. So, either we revert this change and put a warning in the documentation or we make the method throw if a provider has not been given at construction.

Comment thread src/wallet-manager-evm.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Continuing the discussion about not disposing signers that we don't own, we should also update the implementation of the 'dispose' method both in wdk-wallet and wallet-evm since at the moment we are disposing such components. In the 'WalletManager' abstract class, the 'dispose' method should just take care of disposing its accounts. This will also dispose any signers that we own thanks to the 'shouldWipeSignerOnDisposal' option and the corresponding changes. So, such method becomes just:

// @tetherto/wdk-wallet/src/wallet-manager.js
/** @abstract */
export default class WalletManager {
  /**
   * Disposes all the wallet accounts, erasing their private keys from the memory.
   */
  dispose () {
    for (const account of Object.values(this._accounts)) {
      if (account.keyPair?.privateKey) {
        account.dispose()
      }
    }

    this._accounts = {}
  }

In the case the user initializes the wallet manager by providing a seed, we would own the resulting seed signer so we need to wipe it on calls to the 'dispose' method. In order to achieve this, we can just override the 'dispose' method in the 'WalletManagerEvm' class:

export default class WalletManagerEvm extends WalletManager {
  constructor (seedOrSigner, config = {}) {
    [...]

    /**
     * If true, disposes the default signer on calls to the 'dispose' method.
     *
     * @protected
     * @type {boolean}
     */
    this._shouldWipeDefaultSignerOnDisposal = typeof seedOrSigner === 'string' || seedOrSigner instanceof Uint8Array
  }

  /**
   * Disposes all the wallet accounts, erasing their private keys from the memory.
   */
  dispose () {
    super.dispose()

    if (this._shouldWipeDefaultSignerOnDisposal) {
      this._defaultSigner.dispose()
    }
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants